🔷 ¿Qué son las expresiones lambda?
Una expresión lambda en Java es una función anónima —es decir, una función sin nombre— que permite implementar de forma concisa el único método abstracto de una interfaz funcional. Las lambdas representan uno de los cambios más significativos en la historia del lenguaje Java: permiten tratar el comportamiento como un dato que puede almacenarse en variables, pasarse como argumento a métodos y devolverse como resultado.
Antes de Java 8, para pasar un fragmento de comportamiento a un método (por ejemplo, un comparador a Collections.sort()), era necesario crear una clase anónima completa. Las expresiones lambda eliminan esa verbosidad y acercan Java al paradigma de la programación funcional, manteniendo plena compatibilidad con todo el código orientado a objetos existente.
La idea central es sencilla: si una interfaz tiene un único método abstracto, la lambda proporciona la implementación de ese método de forma directa, sin necesidad de la ceremonia habitual de clases e instanciación.
// ❌ Antes de Java 8: clase anónima
Comparator<String> comp1 = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
};
// ✅ Con lambda en Java 8+
Comparator<String> comp2 = (a, b) -> a.length() - b.length();
invokedynamic (introducida en Java 7 para lenguajes dinámicos sobre la JVM), lo que produce código más eficiente que la generación de clases .class adicionales.
📜 Contexto histórico: Java 8 y la revolución funcional
Las expresiones lambda llegaron a Java con la versión Java SE 8, publicada el 18 de marzo de 2014. Su desarrollo se llevó a cabo dentro del Proyecto Lambda (JSR 335), liderado por Brian Goetz, arquitecto del lenguaje en Oracle. Esta versión fue posiblemente la actualización más transformadora de Java desde la introducción de los generics en Java 5 (2004).
La necesidad de lambdas en Java venía de lejos. Lenguajes como Lisp (1958) ya manejaban funciones como ciudadanos de primera clase, y lenguajes modernos de la JVM como Scala y Kotlin ofrecían esta capacidad desde sus inicios. La presión competitiva de C# (que incorporó lambdas en 2007 con LINQ) y la demanda de la comunidad fueron factores decisivos para que Oracle priorizara esta característica.
Java 8 no solo introdujo lambdas: llegó con un ecosistema completo de soporte que incluía las interfaces funcionales, la API de Streams, el paquete java.util.function y los métodos default en interfaces. Juntas, estas características transformaron la forma de escribir código Java, permitiendo un estilo más declarativo y funcional sin renunciar a la orientación a objetos.
| Versión de Java | Año | Aportación a la programación funcional |
|---|---|---|
| Java 5 | 2004 | Generics (tipado parametrizado) |
| Java 7 | 2011 | invokedynamic en la JVM (base técnica para lambdas) |
| Java 8 | 2014 | Lambdas, Streams, interfaces funcionales, métodos default |
| Java 10 | 2018 | var para inferencia de tipos locales |
| Java 14-17 | 2020-2021 | Records, sealed classes, pattern matching |
⚙️ Sintaxis de las expresiones lambda
La sintaxis de una expresión lambda en Java sigue una estructura clara dividida en tres partes: la lista de parámetros, el operador flecha (->) y el cuerpo de la lambda.
(parámetros) -> expresión
// o bien:
(parámetros) -> { bloque de sentencias; }
▶️ Variantes de sintaxis
Java permite varias formas de escribir lambdas, desde la más explícita hasta la más concisa. El compilador infiere los tipos de los parámetros del contexto (la interfaz funcional a la que se asigna la lambda), por lo que generalmente no es necesario declararlos.
// 1. Sin parámetros
Runnable r = () -> System.out.println("Hola");
// 2. Un parámetro (paréntesis opcionales)
Consumer<String> c = s -> System.out.println(s);
Consumer<String> c2 = (s) -> System.out.println(s); // equivalente
// 3. Múltiples parámetros (paréntesis obligatorios)
BinaryOperator<Integer> suma = (a, b) -> a + b;
// 4. Con tipos explícitos (poco habitual)
BinaryOperator<Integer> suma2 = (Integer a, Integer b) -> a + b;
// 5. Cuerpo con múltiples líneas (llaves + return explícito)
Function<String, Integer> parsear = (texto) -> {
texto = texto.trim();
return Integer.parseInt(texto);
};
// 6. Lambda que no devuelve nada
Consumer<String> imprimir = msg -> {
System.out.println("Mensaje: " + msg);
System.out.println("Longitud: " + msg.length());
};
return; Java evalúa la expresión y devuelve su resultado automáticamente. Si el cuerpo tiene varias sentencias, se requieren llaves y, si la interfaz espera un valor de retorno, un return explícito.
🧩 Interfaces funcionales
Una interfaz funcional es una interfaz Java que contiene exactamente un método abstracto (conocido como SAM: Single Abstract Method). Esta restricción es lo que permite a las lambdas saber qué método están implementando. La interfaz puede tener cualquier número de métodos default o static adicionales sin perder su condición de funcional.
Java proporciona la anotación @FunctionalInterface para marcar explícitamente estas interfaces. Aunque la anotación no es obligatoria (cualquier interfaz con un solo método abstracto ya es funcional), se recomienda usarla porque el compilador generará un error si la interfaz no cumple la condición.
@FunctionalInterface
public interface Transformador<T> {
T transformar(T valor);
// Métodos default permitidos: no rompen la condición funcional
default Transformador<T> yLuego(Transformador<T> otra) {
return valor -> otra.transformar(this.transformar(valor));
}
}
// Uso con lambda
Transformador<String> aMayusculas = s -> s.toUpperCase();
Transformador<String> sinEspacios = s -> s.trim();
// Composición
Transformador<String> limpiar = sinEspacios.yLuego(aMayusculas);
System.out.println(limpiar.transformar(" hola mundo "));
// Salida: HOLA MUNDO
🔹 Interfaces funcionales del JDK que ya conoces
Muchas interfaces clásicas de Java ya eran funcionales antes de Java 8, simplemente no se les llamaba así. Estas interfaces se usan habitualmente con lambdas:
| Interfaz | Método abstracto | Uso habitual |
|---|---|---|
Runnable |
void run() |
Ejecutar código en hilos |
Callable<V> |
V call() |
Tareas con resultado en concurrencia |
Comparator<T> |
int compare(T, T) |
Ordenar colecciones |
ActionListener |
void actionPerformed(e) |
Eventos en Swing/AWT |
📦 El paquete java.util.function
Java 8 introdujo el paquete java.util.function con más de 40 interfaces funcionales predefinidas. Las cuatro interfaces más importantes son los pilares sobre los que se construye gran parte de la API de Streams y la programación funcional en Java:
| Interfaz | Firma del método | Recibe | Devuelve | Ejemplo de uso |
|---|---|---|---|---|
Predicate<T> |
boolean test(T) |
T | boolean | Filtrar elementos |
Function<T,R> |
R apply(T) |
T | R | Transformar datos |
Consumer<T> |
void accept(T) |
T | void | Realizar acciones |
Supplier<T> |
T get() |
nada | T | Proveer/generar valores |
import java.util.function.*;
public class InterfacesFuncionalesDemo {
public static void main(String[] args) {
// Predicate: ¿es mayor de edad?
Predicate<Integer> esMayor = edad -> edad >= 18;
System.out.println(esMayor.test(25)); // true
System.out.println(esMayor.test(15)); // false
// Function: convertir nombre a su longitud
Function<String, Integer> longitud = String::length;
System.out.println(longitud.apply("Lambda")); // 6
// Consumer: imprimir con formato
Consumer<String> saludar = nombre ->
System.out.println("¡Hola, " + nombre + "!");
saludar.accept("Ana"); // ¡Hola, Ana!
// Supplier: generar un número aleatorio
Supplier<Double> aleatorio = Math::random;
System.out.println(aleatorio.get()); // 0.7362... (varía)
}
}
🔹 Variantes especializadas
El paquete incluye versiones especializadas para tipos primitivos (IntPredicate, LongFunction, DoubleConsumer...) que evitan el autoboxing y mejoran el rendimiento. También ofrece variantes binarias como BiFunction<T,U,R>, BiPredicate<T,U> y BiConsumer<T,U> para operaciones que reciben dos argumentos, y operadores como UnaryOperator<T> (extiende Function<T,T>) y BinaryOperator<T> (extiende BiFunction<T,T,T>).
java.util.function. En la mayoría de los casos, las interfaces estándar cubren la necesidad y hacen que tu código sea más idiomático y legible para otros desarrolladores Java.
🌊 Lambdas con colecciones y Streams
El verdadero potencial de las lambdas se manifiesta al combinarlas con la API de Streams, también introducida en Java 8. Un Stream es una secuencia de elementos sobre la que se pueden encadenar operaciones de filtrado, transformación, ordenación y reducción de forma declarativa, sin bucles explícitos.
Las lambdas son el mecanismo que alimenta cada operación del Stream: filter() recibe un Predicate, map() recibe una Function, forEach() recibe un Consumer, y así sucesivamente.
import java.util.*;
import java.util.stream.*;
public class StreamsConLambdas {
public static void main(String[] args) {
List<String> nombres = Arrays.asList(
"Ana", "Pedro", "María", "Juan", "Alejandra", "Luis"
);
// Filtrar, transformar y recolectar en una sola cadena
List<String> resultado = nombres.stream()
.filter(n -> n.length() > 4) // Predicate: más de 4 letras
.map(String::toUpperCase) // Function: a mayúsculas
.sorted() // Orden natural
.collect(Collectors.toList()); // Recoger en lista
System.out.println(resultado);
// Salida: [ALEJANDRA, MARÍA, PEDRO]
}
}
🔹 Operaciones más comunes con lambdas en Streams
| Operación | Tipo | Lambda que recibe | Propósito |
|---|---|---|---|
filter() |
Intermedia | Predicate<T> |
Conservar elementos que cumplen condición |
map() |
Intermedia | Function<T,R> |
Transformar cada elemento |
sorted() |
Intermedia | Comparator<T> |
Ordenar elementos |
forEach() |
Terminal | Consumer<T> |
Realizar acción sobre cada elemento |
reduce() |
Terminal | BinaryOperator<T> |
Combinar todos los elementos en uno |
anyMatch() |
Terminal | Predicate<T> |
¿Algún elemento cumple la condición? |
List<Integer> numeros = Arrays.asList(3, 7, 12, 5, 8, 21, 14, 2);
// Suma de los pares
int sumaPares = numeros.stream()
.filter(n -> n % 2 == 0)
.reduce(0, Integer::sum);
System.out.println("Suma de pares: " + sumaPares); // 36
// El mayor de los impares
Optional<Integer> maxImpar = numeros.stream()
.filter(n -> n % 2 != 0)
.max(Comparator.naturalOrder());
maxImpar.ifPresent(v -> System.out.println("Mayor impar: " + v)); // 21
// Convertir a strings con formato
String formateados = numeros.stream()
.map(n -> "[" + n + "]")
.collect(Collectors.joining(", "));
System.out.println(formateados); // [3], [7], [12], [5], [8], [21], [14], [2]
🔗 Referencias a métodos (Method References)
Cuando una lambda simplemente llama a un método existente sin añadir lógica adicional, se puede usar una referencia a método como atajo más legible. Las referencias a métodos usan el operador :: y existen cuatro tipos:
| Tipo | Sintaxis | Lambda equivalente | Ejemplo |
|---|---|---|---|
| Método estático | Clase::metodo |
x -> Clase.metodo(x) |
Integer::parseInt |
| Método de instancia (objeto concreto) | obj::metodo |
x -> obj.metodo(x) |
System.out::println |
| Método de instancia (tipo arbitrario) | Clase::metodo |
x -> x.metodo() |
String::toUpperCase |
| Constructor | Clase::new |
x -> new Clase(x) |
ArrayList::new |
List<String> palabras = Arrays.asList("java", "lambda", "stream");
// Lambda explícita
palabras.forEach(p -> System.out.println(p));
// Method reference equivalente (más idiomático)
palabras.forEach(System.out::println);
// Ordenar por longitud — lambda
palabras.sort((a, b) -> Integer.compare(a.length(), b.length()));
// Ordenar por longitud — method reference con Comparator
palabras.sort(Comparator.comparingInt(String::length));
// Convertir strings a enteros — lambda vs method reference
Function<String, Integer> parser1 = s -> Integer.parseInt(s);
Function<String, Integer> parser2 = Integer::parseInt; // más limpio
🔒 Closures y captura de variables
Las lambdas en Java pueden acceder a variables del ámbito que las rodea, comportándose como closures. Sin embargo, Java impone una restricción importante: solo pueden capturar variables locales que sean efectivamente finales (effectively final), es decir, variables cuyo valor no cambia después de la inicialización.
public class ClosureDemo {
private int atributo = 10; // Los atributos de instancia SÍ pueden cambiar
public void ejemplo() {
int multiplicador = 3; // Effectively final: nunca se reasigna
// ✅ Correcto: captura variable effectively final
Function<Integer, Integer> triplicar = n -> n * multiplicador;
System.out.println(triplicar.apply(5)); // 15
// ✅ Correcto: captura atributo de instancia (acceso vía this)
Consumer<Integer> sumar = n -> this.atributo += n;
sumar.accept(5);
System.out.println(this.atributo); // 15
// ❌ ERROR DE COMPILACIÓN:
// int contador = 0;
// Runnable inc = () -> contador++; // No compila: contador no es effectively final
}
}
🔹 ¿Por qué esta restricción?
Las variables locales viven en la pila (stack) del hilo que ejecuta el método. Cuando el método termina, la variable desaparece. Pero la lambda puede sobrevivir al método (por ejemplo, si se almacena en una colección o se pasa a otro hilo). Java resuelve esto copiando el valor de la variable capturada en la lambda. Si la variable pudiera cambiar después de la copia, habría inconsistencia entre el valor en la pila y el valor en la lambda, lo que conduciría a bugs sutiles y difíciles de detectar.
int[] contador = {0}) o AtomicInteger para sortear la restricción. Esto funciona porque la referencia al array es effectively final (el array no cambia), aunque su contenido sí. Es una práctica válida en contextos simples, pero puede generar problemas en concurrencia si la lambda se ejecuta en otro hilo.
🛒 Ejemplo integrador: sistema de filtrado de productos
Este ejemplo muestra cómo las lambdas, las interfaces funcionales y los Streams trabajan juntos en un caso realista: un sistema de comercio electrónico que necesita filtrar, transformar y presentar productos según criterios dinámicos.
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
// Modelo de datos
class Producto {
private String nombre;
private String categoria;
private double precio;
private int stock;
public Producto(String nombre, String categoria, double precio, int stock) {
this.nombre = nombre;
this.categoria = categoria;
this.precio = precio;
this.stock = stock;
}
// Getters
public String getNombre() { return nombre; }
public String getCategoria() { return categoria; }
public double getPrecio() { return precio; }
public int getStock() { return stock; }
@Override
public String toString() {
return String.format("%s (%.2f€, stock: %d)", nombre, precio, stock);
}
}
// Motor de búsqueda con lambdas
class BuscadorProductos {
// Método genérico que acepta cualquier combinación de filtros
public static List<Producto> buscar(
List<Producto> catalogo,
Predicate<Producto> filtro,
Comparator<Producto> orden,
int limite) {
return catalogo.stream()
.filter(filtro)
.sorted(orden)
.limit(limite)
.collect(Collectors.toList());
}
// Fábrica de predicados reutilizables
public static Predicate<Producto> precioEntre(double min, double max) {
return p -> p.getPrecio() >= min && p.getPrecio() <= max;
}
public static Predicate<Producto> categoriaEs(String cat) {
return p -> p.getCategoria().equalsIgnoreCase(cat);
}
public static Predicate<Producto> conStock() {
return p -> p.getStock() > 0;
}
}
// Programa principal
public class TiendaLambda {
public static void main(String[] args) {
List<Producto> catalogo = Arrays.asList(
new Producto("Teclado mecánico", "Periféricos", 89.99, 15),
new Producto("Monitor 27\"", "Monitores", 349.00, 3),
new Producto("Ratón inalámbrico", "Periféricos", 45.50, 42),
new Producto("SSD 1TB", "Almacenamiento", 79.99, 0),
new Producto("Webcam HD", "Periféricos", 62.00, 28),
new Producto("RAM 16GB DDR5", "Componentes", 125.00, 11),
new Producto("Monitor 24\"", "Monitores", 199.00, 7),
new Producto("Hub USB-C", "Periféricos", 34.99, 55)
);
// Buscar periféricos con stock, entre 30€ y 70€, ordenados por precio
Predicate<Producto> filtro = BuscadorProductos.categoriaEs("Periféricos")
.and(BuscadorProductos.conStock())
.and(BuscadorProductos.precioEntre(30, 70));
List<Producto> resultados = BuscadorProductos.buscar(
catalogo,
filtro,
Comparator.comparingDouble(Producto::getPrecio),
10
);
System.out.println("=== Periféricos (30-70€, con stock) ===");
resultados.forEach(System.out::println);
// Calcular el valor total del stock
double valorTotal = catalogo.stream()
.filter(BuscadorProductos.conStock())
.mapToDouble(p -> p.getPrecio() * p.getStock())
.sum();
System.out.printf("\nValor total en stock: %.2f€%n", valorTotal);
// Resumen por categoría
Map<String, Long> porCategoria = catalogo.stream()
.collect(Collectors.groupingBy(
Producto::getCategoria,
Collectors.counting()
));
System.out.println("\nProductos por categoría: " + porCategoria);
}
}
/* Salida esperada:
=== Periféricos (30-70€, con stock) ===
Hub USB-C (34,99€, stock: 55)
Ratón inalámbrico (45,50€, stock: 42)
Webcam HD (62,00€, stock: 28)
Valor total en stock: 14.384,27€
Productos por categoría: {Monitores=2, Almacenamiento=1, Periféricos=4, Componentes=1}
*/
⚠️ Errores frecuentes con lambdas
| Error | Código incorrecto | Solución |
|---|---|---|
| Modificar variable local capturada | int c = 0; list.forEach(x -> c++); |
Usar AtomicInteger o reduce() |
Olvidar return en bloque |
x -> { x * 2; } |
x -> { return x * 2; } o quitar llaves: x -> x * 2 |
| Lambda en interfaz no funcional | Asignar lambda a interfaz con 2+ métodos abstractos | Verificar que la interfaz tiene exactamente 1 método abstracto |
| Excepciones checked en lambdas | list.forEach(f -> new FileReader(f)); |
Envolver en try-catch dentro de la lambda o usar wrapper |
| Confundir tipos en method reference | String::compareTo como Comparator<String> |
Verificar la firma: compareTo es (String) -> int, no (String, String) -> int |
// ❌ No compila: FileReader lanza IOException (checked)
// list.forEach(f -> new FileReader(f));
// ✅ Solución 1: try-catch dentro de la lambda
list.forEach(f -> {
try {
new FileReader(f);
} catch (IOException e) {
throw new RuntimeException("Error al abrir: " + f, e);
}
});
// ✅ Solución 2: método wrapper reutilizable
@FunctionalInterface
interface CheckedFunction<T, R> {
R apply(T t) throws Exception;
}
static <T, R> Function<T, R> unchecked(CheckedFunction<T, R> fn) {
return t -> {
try {
return fn.apply(t);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
// Uso limpio
list.stream()
.map(unchecked(f -> new FileReader(f)))
.forEach(System.out::println);
📝 Ejercicios prácticos
Ejercicio 1 — Filtrado con Predicate (Principiante)
Dada una lista de enteros, usa una lambda con filter() para obtener solo los números mayores que 10 y divisibles entre 3. Imprime el resultado.
Ver solución
import java.util.*;
import java.util.stream.*;
public class Ejercicio1 {
public static void main(String[] args) {
List<Integer> nums = Arrays.asList(5, 12, 7, 15, 3, 21, 9, 30, 18, 4);
List<Integer> resultado = nums.stream()
.filter(n -> n > 10 && n % 3 == 0)
.collect(Collectors.toList());
System.out.println(resultado); // [12, 15, 21, 30, 18]
}
}
Ejercicio 2 — Transformación con Function (Intermedio)
Crea un método aplicarTransformaciones que reciba una lista de strings y una lista de Function<String, String>, y aplique todas las transformaciones en secuencia a cada string. Prueba con: recortar espacios, convertir a mayúsculas, y añadir un prefijo «[PROCESADO] ».
Ver solución
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
public class Ejercicio2 {
public static List<String> aplicarTransformaciones(
List<String> datos,
List<Function<String, String>> transformaciones) {
// Componer todas las funciones en una sola
Function<String, String> pipeline = transformaciones.stream()
.reduce(Function.identity(), Function::andThen);
return datos.stream()
.map(pipeline)
.collect(Collectors.toList());
}
public static void main(String[] args) {
List<String> datos = Arrays.asList(" hola ", " mundo ", " java ");
List<Function<String, String>> ops = Arrays.asList(
String::trim,
String::toUpperCase,
s -> "[PROCESADO] " + s
);
List<String> resultado = aplicarTransformaciones(datos, ops);
resultado.forEach(System.out::println);
// [PROCESADO] HOLA
// [PROCESADO] MUNDO
// [PROCESADO] JAVA
}
}
Ejercicio 3 — Sistema de validación con Predicate (Avanzado)
Diseña un sistema de validación para objetos Usuario (nombre, email, edad) usando composición de Predicate. Cada regla es un predicado independiente. El método validar debe recibir un usuario y una lista de predicados, devolviendo una lista de mensajes de error (strings). Si todos los predicados se cumplen, la lista queda vacía.
Ver solución
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
class Usuario {
String nombre;
String email;
int edad;
Usuario(String nombre, String email, int edad) {
this.nombre = nombre;
this.email = email;
this.edad = edad;
}
}
// Par de (predicado, mensaje de error si falla)
class Regla<T> {
Predicate<T> condicion;
String mensajeError;
Regla(Predicate<T> condicion, String mensajeError) {
this.condicion = condicion;
this.mensajeError = mensajeError;
}
}
class Validador {
public static <T> List<String> validar(T objeto, List<Regla<T>> reglas) {
return reglas.stream()
.filter(r -> !r.condicion.test(objeto)) // reglas que NO se cumplen
.map(r -> r.mensajeError)
.collect(Collectors.toList());
}
}
public class Ejercicio3 {
public static void main(String[] args) {
List<Regla<Usuario>> reglas = Arrays.asList(
new Regla<>(u -> u.nombre != null && !u.nombre.isBlank(),
"El nombre no puede estar vacío"),
new Regla<>(u -> u.email != null && u.email.contains("@"),
"El email debe contener @"),
new Regla<>(u -> u.edad >= 18,
"Debe ser mayor de edad"),
new Regla<>(u -> u.edad <= 120,
"La edad no es válida")
);
Usuario u1 = new Usuario("Ana", "ana@mail.com", 25);
Usuario u2 = new Usuario("", "sin-arroba", 15);
System.out.println("Usuario 1: " + Validador.validar(u1, reglas));
// []
System.out.println("Usuario 2: " + Validador.validar(u2, reglas));
// [El nombre no puede estar vacío, El email debe contener @, Debe ser mayor de edad]
}
}
❓ Preguntas frecuentes sobre Expresiones lambda en Java
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Expresiones lambda en Java? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!