Programación en Java
Lambdas, referencias a métodos e interfaces funcionales en Java
Intermedio9 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Las lambdas convirtieron Java en un lenguaje en el que el comportamiento se pasa de un lado a otro con la misma facilidad que los datos. Esta guía explica qué es realmente una lambda —una instancia de una interfaz funcional—, cómo infiere el compilador su tipo, las interfaces predefinidas de java.util.function, los cuatro tipos de referencia a métodos, las reglas para capturar variables y cómo se compilan las lambdas. Podrás escribir, leer y diseñar API basadas en lambdas con total seguridad.
Lo que aprenderás
- Explicar cómo se tipa una expresión lambda frente a una interfaz funcional
- Elegir la interfaz de java.util.function adecuada, incluidas las especializaciones para primitivos
- Convertir entre lambdas y las cuatro formas de referencia a métodos
- Aplicar la regla effectively final y entender por qué una lambda no puede modificar las variables locales capturadas
- Componer funciones, predicados y comparadores, y diseñar métodos que reciban comportamiento
Java 8 incorporó las expresiones lambda y, con ellas, un cambio en la forma de escribir código Java: el comportamiento pasó a ser un argumento de primera clase. La ordenación, el filtrado, las políticas de reintento, los manejadores de eventos y los cuerpos de los hilos pasaron a ser una sola línea en lugar de una clase anónima. La sintaxis es sencilla; lo que confunde es el modelo de tipos (una lambda no tiene tipo propio), las reglas de captura y el catálogo cada vez mayor de interfaces de java.util.function. Esta guía cubre esos fundamentos, de los que dependen la API de streams y buena parte del Java moderno. El código está pensado para Java 21.
Qué es una lambda
Una expresión lambda es una forma compacta de crear una instancia de una interfaz funcional: una interfaz con exactamente un método abstracto. Los parámetros y el cuerpo de la lambda se convierten en la implementación de ese método.
// Before: an anonymous class
Comparator<String> byLength = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
// After: a lambda
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());
La forma es parameters -> body. Los tipos de los parámetros suelen inferirse; un único parámetro no necesita paréntesis; un cuerpo formado por una sola expresión no necesita llaves ni return; un cuerpo de bloque usa ambos. Desde Java 11 se admite var en los tipos de los parámetros, sobre todo para poder anotarlos.
Runnable hello = () -> System.out.println("hi"); // no parameters
Function<String, Integer> len = s -> s.length(); // one parameter, expression body
BiFunction<Integer, Integer, Integer> add = (Integer a, Integer b) -> { // explicit types, block body
int sum = a + b;
return sum;
};
El tipo destino
Una lambda no tiene un tipo intrínseco. (a, b) -> a + b podría ser un BinaryOperator<Integer>, una BiFunction<String, String, String> o un Comparator<Integer> (uno malo). El compilador determina el tipo destino a partir del contexto —la variable a la que se asigna, el parámetro del método, el tipo de retorno, un cast— y comprueba la lambda contra el único método abstracto de esa interfaz. Por eso no puedes escribir var f = x -> x + 1; (no hay destino) y por eso los métodos sobrecargados que aceptan distintas interfaces funcionales a veces necesitan un cast o tipos de parámetro explícitos para deshacer la ambigüedad.
Sirve cualquier interfaz con un solo método abstracto, incluidas las tuyas y otras antiguas como Runnable, Callable, Comparator y ActionListener. Marca las tuyas con @FunctionalInterface: la anotación no es obligatoria, pero hace que el compilador rechace un segundo método abstracto que, de otro modo, rompería en silencio todas las lambdas dirigidas a esa interfaz.
El catálogo de java.util.function
En lugar de que cada biblioteca invente sus propias interfaces de un solo método, el JDK ofrece un conjunto estándar. Aprende las seis formas básicas y el esquema de nombres, y podrás descifrar todas las demás.
| Interfaz | Método | Significado |
|---|---|---|
Supplier<T> | T get() | Producir un valor, sin entrada |
Consumer<T> | void accept(T t) | Usar un valor, sin devolver nada |
Function<T, R> | R apply(T t) | Transformar T en R |
Predicate<T> | boolean test(T t) | Decisión de sí o no |
UnaryOperator<T> | T apply(T t) | Función de T a T |
BinaryOperator<T> | T apply(T a, T b) | Combinar dos T en un solo T |
El prefijo Bi duplica las entradas (BiFunction<T, U, R>, BiConsumer<T, U>, BiPredicate<T, U>). Hay especializaciones para primitivos que evitan el boxing: IntPredicate, IntFunction<R> (recibe un int), ToIntFunction<T> (devuelve un int), IntUnaryOperator, IntBinaryOperator, IntSupplier, ObjIntConsumer<T>, con sus variantes Long y Double. Por eso un stream llama a mapToInt(ToIntFunction).
Supplier<List<String>> newList = ArrayList::new;
Consumer<String> print = System.out::println;
Function<String, Integer> parse = Integer::parseInt;
Predicate<String> isBlank = String::isBlank;
UnaryOperator<String> trim = String::strip;
BinaryOperator<Integer> max = Integer::max;
IntBinaryOperator gcd = (a, b) -> { while (b != 0) { int t = b; b = a % b; a = t; } return a; };
Recurre a estas interfaces antes de definir las tuyas. Una interfaz funcional propia está justificada cuando el nombre aporta significado de dominio (PricingRule, RetryPolicy) o cuando el método debe declarar una excepción checked, algo que no hace ninguna de las interfaces estándar.
Referencias a métodos
Una referencia a método nombra un método existente allí donde una lambda se limitaría a llamarlo. Hay cuatro formas:
| Forma | Sintaxis | Lambda equivalente |
|---|---|---|
| Método estático | Integer::parseInt | s -> Integer.parseInt(s) |
| Método de instancia de un objeto concreto | System.out::println | x -> System.out.println(x) |
| Método de instancia de un objeto cualquiera de un tipo | String::toUpperCase | s -> s.toUpperCase() |
| Constructor | ArrayList::new | () -> new ArrayList<>() |
La tercera forma es la que al principio resulta extraña: String::compareToIgnoreCase usado como Comparator<String> significa «llama a compareToIgnoreCase sobre el primer argumento, pasándole el segundo». El primer parámetro se convierte en el receptor.
Las referencias ligadas (la segunda forma) evalúan la expresión del receptor una sola vez, cuando se crea la referencia. this::handle o logger::info capturan el objeto actual; si más tarde el objeto cambia, la referencia sigue apuntando al anterior.
Las referencias a métodos no siempre son más claras. Person::getName es mejor que p -> p.getName(); pero entre (a, b) -> a.compareTo(b) y String::compareTo es cuestión de gustos, y una referencia a un método sobrecargado puede dejar al compilador sin saber qué sobrecarga elegir. Ante la duda, escribe la lambda.
Captura de variables y la regla effectively final
El cuerpo de una lambda puede leer variables locales, parámetros, campos y this del ámbito que la rodea. Sin embargo, las variables locales y los parámetros deben ser effectively final: asignados exactamente una vez, escribas o no la palabra clave final. El compilador rechaza una lambda que escriba en una variable local, y también una lambda que lea una variable local que se modifique en cualquier punto posterior a su inicialización.
int base = 10;
Function<Integer, Integer> addBase = x -> x + base; // fine: base is effectively final
int counter = 0;
list.forEach(item -> counter++); // compile error: counter is modified
La regla existe porque la lambda recibe una copia del valor. La variable local vive en el marco de pila del método contenedor; la lambda puede ejecutarse cuando ese marco ya no existe, o en otro hilo. Compartir una posición mutable obligaría a la JVM a mover la variable al heap y a sincronizar el acceso, y los diseñadores del lenguaje prefirieron prohibirlo antes que ocultar ese coste.
Cuando de verdad necesitas acumular, las opciones, por orden de preferencia, son: expresar el cálculo como una reducción o un collector para que no exista estado compartido; usar un AtomicInteger o un LongAdder cuando la lambda es un callback que tiene que contar; o, como último recurso, un array de un elemento (int[] counter = {0}), que compila porque la referencia es final aunque cambie su contenido, y que es una condición de carrera si la lambda se ejecuta de forma concurrente.
Los campos no están sujetos a la regla. this.counter++ dentro de una lambda compila, porque lo que se captura es this y el campo se accede a través de él. Eso resulta cómodo en un manejador de interfaz gráfica de un solo hilo y es un bug en un parallel stream.
this en lambdas frente a clases anónimas
Dentro de una lambda, this es la instancia contenedora, exactamente igual que en el método que la rodea. Dentro de una clase anónima, this es el propio objeto anónimo. Esta diferencia es la razón más visible de que una lambda no sea azúcar sintáctico de una clase anónima: convertir una en la otra puede cambiar a qué se refiere this.name.
Composición
Las interfaces funcionales incluyen métodos default para combinar instancias, de modo que se pueden ensamblar piezas pequeñas sin escribir lambdas nuevas.
Predicate<String> notBlank = Predicate.not(String::isBlank);
Predicate<String> shortEnough = s -> s.length() <= 80;
Predicate<String> validTitle = notBlank.and(shortEnough);
Function<String, String> normalise = String::strip;
Function<String, String> pipeline = normalise.andThen(String::toLowerCase); // strip, then lower-case
Function<String, Integer> lengthAfter = pipeline.andThen(String::length);
Comparator<Employee> ordering = Comparator.comparing(Employee::department)
.thenComparing(Employee::salary, Comparator.reverseOrder())
.thenComparing(Employee::name, String.CASE_INSENSITIVE_ORDER);
UnaryOperator<String> identity = UnaryOperator.identity();
andThen aplica primero el receptor y después el argumento; compose hace lo contrario. Predicate ofrece and, or, negate y el método estático not. Comparator es la más completa: comparing, comparingInt, thenComparing, reversed, nullsFirst, nullsLast y naturalOrder. Componer comparadores así sustituye a los métodos compare escritos a mano, que eran una fuente inagotable de errores de signo.
Diseñar API en torno a las lambdas
La ventaja de las lambdas no se limita a llamadas más cortas: permiten construir bibliotecas en torno a la parametrización del comportamiento, es decir, métodos que reciben una estrategia como argumento en lugar de un flag. Compara un método con tres parámetros booleanos con otro que recibe un Predicate<Order>: el segundo es más pequeño, más general y se puede probar sin el código que lo llama.
public final class Retry {
public static <T> T withRetry(int attempts, Predicate<Exception> retryable, Supplier<T> action) {
Exception last = null;
for (int i = 0; i < attempts; i++) {
try {
return action.get();
} catch (Exception e) {
if (!retryable.test(e)) throw e instanceof RuntimeException r ? r : new RuntimeException(e);
last = e;
}
}
throw new IllegalStateException("Gave up after " + attempts + " attempts", last);
}
}
String body = Retry.withRetry(3, e -> e instanceof IOException, () -> client.fetch("/health"));
Pautas para este tipo de API:
- Acepta la interfaz estándar más general que encaje:
Supplier<T>en lugar de unLoader<T>propio;Function<? super T, ? extends R>con comodines, como explica la guía de genéricos, para que quien llama pueda pasar algo más que los tipos exactos. - Prefiere una especialización para primitivos cuando el valor sea primitivo;
IntPredicateevita el boxing en un bucle crítico. - Haz que la lambda sea pura siempre que puedas, y documenta si la API la invoca más de una vez, de forma perezosa o en otro hilo.
- Plantéate una pequeña interfaz propia cuando mejore la legibilidad de la llamada o necesite una excepción checked.
La única limitación real son las excepciones checked. Un Supplier<T> no puede lanzar IOException, así que una lambda que llama a Files.readString tiene que capturarla y envolverla. O bien declaras tu propio ThrowingSupplier<T, E extends Exception> para las API en las que eso es lo esperado, o bien la envuelves en una UncheckedIOException en el punto de llamada.
Cómo se compilan las lambdas
Entender la implementación explica el comportamiento. El compilador convierte el cuerpo de una lambda en un método sintético privado de la clase contenedora y emite una instrucción invokedynamic en el punto donde aparece la lambda. En la primera ejecución, la LambdaMetafactory de la JVM genera una pequeña clase oculta que implementa la interfaz funcional y enlaza con ella el punto de llamada. Consecuencias:
- No hay ficheros .class adicionales en tu JAR, a diferencia de las clases anónimas, que generan cada una un
Outer$1.class. - Las lambdas sin captura (sin referencias a variables locales ni a
this) se crean una vez y se almacenan en caché; cada evaluación devuelve la misma instancia. Las lambdas con captura crean una instancia nueva en cada evaluación, con copias de los valores capturados. - La identidad no está especificada. Nunca compares lambdas con
==ni las uses como claves de un map; dos evaluaciones del mismo código fuente pueden ser o no el mismo objeto. - La serialización solo funciona si la interfaz destino es
Serializable, y es frágil entre recompilaciones. Evítala.
En cuanto a rendimiento, una llamada a una lambda tras el calentamiento del JIT es comparable a una llamada a una interfaz y normalmente se le aplica inlining. Los costes que aparecen en los perfiles vienen del boxing (usa las interfaces para primitivos), de las asignaciones por captura en bucles críticos y de los puntos de llamada megamórficos, en los que una misma línea de código despacha hacia muchas lambdas distintas.
Errores comunes
Modificar estado capturado. El truco de la celda de array y la modificación de campos compilan, pero provocan condiciones de carrera en ejecución paralela. Usa una reducción o un collector.
Cuerpos de lambda largos. Un bloque de diez líneas dentro de un pipeline de streams oculta la forma del pipeline. Extrae un método con nombre y referéncialo.
Sobrecargas que reciben interfaces funcionales distintas. submit(Runnable) y submit(Callable<T>) en ExecutorService ya provocan ambigüedad con () -> doWork(). Evita diseñar sobrecargas así; si no queda más remedio, dales nombres distintos.
Ignorar el desajuste con las excepciones checked. Envolver cada llamada en un try/catch dentro de la lambda es señal de que conviene introducir una interfaz que lance excepciones o reestructurar el código.
Suponer una ejecución inmediata. Pasar una lambda a un método no la ejecuta. Si el método la guarda y la llama más tarde, los objetos capturados siguen vivos hasta entonces, lo que puede retener objetos grandes en memoria.
Usar Optional.get en una cadena de lambdas. Compón con map/flatMap/orElse; consulta la guía de Optional.
Lambdas de comparación con restas. (a, b) -> a.value - b.value desborda con valores grandes. Usa Integer.compare o Comparator.comparingInt.
Puntos clave
- Una lambda es una instancia de una interfaz funcional; su tipo procede del contexto en que aparece, nunca de la propia lambda.
java.util.functionaporta las formas estándar —Supplier,Consumer,Function,Predicate,UnaryOperator,BinaryOperator— más las variantesBiy las variantes para primitivos. Úsalas antes de escribir las tuyas.- Las referencias a métodos tienen cuatro formas; úsalas cuando la lambda se limitaría a reenviar sus argumentos.
- Las variables locales capturadas deben ser effectively final porque la lambda recibe una copia; los campos y
thisse capturan por referencia. - Compón comportamiento con
andThen,compose,and,or,negatey los builders deComparatoren lugar de escribir lambdas nuevas. - Las lambdas se compilan a
invokedynamicmás un método sintético, no a clases anónimas: sin ficheros .class, instancias sin captura en caché,thises el objeto contenedor y la identidad no está garantizada.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Qué es una interfaz funcional?
- Una interfaz con exactamente un método abstracto. Los métodos default y static no cuentan, y tampoco los que ya proporciona Object, como equals. Cualquier interfaz funcional puede ser el tipo destino de una lambda o de una referencia a método. La anotación @FunctionalInterface es opcional, pero hace que el compilador imponga la regla del método único.
- ¿Por qué las variables que usa una lambda deben ser effectively final?
- Una lambda captura el valor de una variable local, no la variable en sí: la variable local vive en la pila y puede haber desaparecido cuando la lambda se ejecute más tarde, quizá en otro hilo. Permitir escrituras haría que la copia y el original divergieran sin avisar. Si necesitas estado mutable, usa un campo, una celda de array, un AtomicInteger o, mejor aún, reestructura el código con una reducción o un collector.
- ¿Una lambda es solo una sintaxis abreviada de una clase interna anónima?
- No. Una lambda se compila a un método privado más un punto de llamada invokedynamic que crea la instancia de la interfaz funcional de forma perezosa; no se genera ningún fichero .class en tiempo de compilación, this se refiere a la instancia contenedora y una lambda sin captura suele crearse una sola vez y reutilizarse. Una clase anónima es una clase aparte, con su propio this y un objeto nuevo en cada evaluación.
- ¿Cuándo conviene usar una referencia a método en lugar de una lambda?
- Cuando la lambda no haría más que pasar sus parámetros a un único método existente: String::length en lugar de s -> s.length(). Si hay que adaptar los argumentos de alguna forma, o si la referencia resultaría ambigua o poco clara, escribe la lambda.