Sintaxis y estructura básica de expresiones lambda
Las expresiones lambda representan una de las características más importantes introducidas en Java 8, permitiendo implementar el paradigma de programación funcional. Una expresión lambda es, esencialmente, un bloque de código que puede ser pasado como argumento a métodos o almacenado en variables, similar a lo que podríamos considerar una función anónima.
La sintaxis básica de una expresión lambda en Java sigue este patrón:
(parámetros) -> expresión
o para bloques de código más extensos:
(parámetros) -> {
// Cuerpo con múltiples instrucciones
return resultado;
}
Componentes de una expresión lambda
Una expresión lambda consta de tres partes principales:
- Lista de parámetros: Encerrados entre paréntesis y separados por comas
- Operador flecha (
->
) - Cuerpo de la lambda: Una expresión o un bloque de código entre llaves
Variaciones en la sintaxis
La sintaxis de las expresiones lambda puede variar según diferentes situaciones:
- Sin parámetros:
() -> System.out.println("Lambda sin parámetros")
- Un solo parámetro (los paréntesis son opcionales):
x -> x * x
o con paréntesis:
(x) -> x * x
- Múltiples parámetros:
(x, y) -> x + y
- Con tipos de datos explícitos:
(int x, int y) -> x + y
- Expresión de una sola línea (sin llaves ni return):
(String s) -> s.length()
- Bloque de código con múltiples líneas:
(String s) -> {
int length = s.length();
return length > 5;
}
Ejemplos prácticos
Veamos algunos ejemplos prácticos de expresiones lambda en diferentes contextos:
- Ordenar una lista de cadenas por longitud:
List<String> nombres = Arrays.asList("Ana", "Juan", "Alejandro", "Eva");
// Antes de Java 8 (clase anónima)
Collections.sort(nombres, new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
// Con expresión lambda
Collections.sort(nombres, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
- Filtrar elementos de una colección:
List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> pares = new ArrayList<>();
// Filtrar números pares
numeros.forEach(n -> {
if (n % 2 == 0) {
pares.add(n);
}
});
- Ejecutar código en un hilo:
// Antes de Java 8
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Ejecutando en otro hilo");
}
}).start();
// Con expresión lambda
new Thread(() -> System.out.println("Ejecutando en otro hilo con lambda")).start();
Reglas de sintaxis importantes
Al trabajar con expresiones lambda, es importante tener en cuenta estas reglas:
- Inferencia de tipos: Java puede inferir los tipos de los parámetros basándose en el contexto.
// El compilador infiere que 'n' es un Integer
List<Integer> numeros = Arrays.asList(1, 2, 3);
numeros.forEach(n -> System.out.println(n));
- Return implícito: En expresiones de una sola línea sin llaves, el valor se retorna automáticamente.
// Estas dos expresiones son equivalentes
Function<Integer, Integer> cuadrado1 = n -> n * n;
Function<Integer, Integer> cuadrado2 = n -> { return n * n; };
- Paréntesis obligatorios: Si no hay parámetros o hay más de uno, los paréntesis son obligatorios.
Runnable tarea = () -> System.out.println("Tarea sin parámetros");
BiFunction<Integer, Integer, Integer> suma = (a, b) -> a + b;
- Llaves obligatorias: Si el cuerpo tiene más de una instrucción, las llaves son obligatorias.
Consumer<String> procesador = s -> {
String resultado = s.toUpperCase();
System.out.println(resultado);
// Más instrucciones...
};
Limitaciones de las expresiones lambda
Las expresiones lambda tienen algunas limitaciones en su sintaxis:
- No pueden declarar variables con el mismo nombre que variables en el ámbito externo.
- No pueden usar la palabra clave
this
para referirse a la instancia de la lambda (se refiere a la clase contenedora). - No pueden usar
break
ocontinue
para salir de la lambda.
// Esto NO compila
int contador = 0;
Runnable r = () -> {
int contador = 1; // Error: Variable 'contador' ya está definida
System.out.println(contador);
};
Las expresiones lambda proporcionan una forma concisa y expresiva de implementar interfaces funcionales, lo que permite escribir código más limpio y legible, especialmente cuando se trabaja con colecciones y operaciones de procesamiento de datos.
¿Te está gustando esta lección?
Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.
Más de 25.000 desarrolladores ya confían en CertiDevs
Contextos de uso y target typing
Las expresiones lambda en Java no existen de forma aislada, sino que siempre se utilizan en un contexto específico. A diferencia de lenguajes como JavaScript o Python, donde las funciones son ciudadanos de primera clase, en Java las lambdas están vinculadas al concepto de interfaces funcionales.
El compilador de Java utiliza un mecanismo llamado target typing (o tipado objetivo) para determinar qué interfaz funcional está implementando una expresión lambda. Este mecanismo permite que el compilador infiera los tipos de los parámetros y el tipo de retorno basándose en el contexto donde se utiliza la lambda.
Interfaces funcionales como objetivos
Para que una expresión lambda pueda ser utilizada, debe existir un "objetivo" que sea una interfaz funcional - una interfaz que contiene exactamente un método abstracto. Algunos ejemplos comunes incluyen:
// Contextos comunes donde se utilizan lambdas
Runnable tarea = () -> System.out.println("Ejecutando tarea");
Comparator<String> comparador = (s1, s2) -> s1.length() - s2.length();
ActionListener listener = e -> System.out.println("Botón pulsado");
En cada caso, el compilador determina qué interfaz funcional está implementando la lambda basándose en el contexto de asignación.
Asignación a variables
El contexto más directo es la asignación a una variable cuyo tipo es una interfaz funcional:
// El tipo de la variable define el "target type"
Predicate<String> estaVacio = s -> s.isEmpty();
Consumer<Integer> mostrar = n -> System.out.println(n);
Supplier<Double> numeroAleatorio = () -> Math.random();
En estos ejemplos, el compilador sabe exactamente qué método abstracto debe implementar la lambda porque está definido por el tipo de la variable.
Parámetros de métodos
Otro contexto común es cuando una lambda se pasa como argumento a un método:
List<String> nombres = Arrays.asList("Ana", "Carlos", "Beatriz");
// La lambda implementa Consumer<String> porque forEach espera ese tipo
nombres.forEach(nombre -> System.out.println(nombre));
// La lambda implementa Predicate<String> porque removeIf espera ese tipo
nombres.removeIf(nombre -> nombre.length() > 5);
El compilador determina el tipo de la lambda basándose en la firma del método al que se pasa como argumento.
Valores de retorno
Las lambdas también pueden ser valores de retorno de métodos:
public Comparator<String> obtenerComparadorPorLongitud() {
// Retorna una lambda que implementa Comparator<String>
return (s1, s2) -> Integer.compare(s1.length(), s2.length());
}
Target typing en expresiones
El target typing también funciona en expresiones más complejas:
// En una expresión condicional
Predicate<String> filtro = condicion
? s -> s.startsWith("A")
: s -> s.endsWith("Z");
// En constructores
new Thread(() -> System.out.println("Nuevo hilo")).start();
Inferencia de tipos
Gracias al target typing, el compilador puede inferir los tipos de los parámetros de la lambda:
// El compilador infiere que 'n' es Integer
List<Integer> numeros = Arrays.asList(1, 2, 3);
numeros.forEach(n -> System.out.println(n * n));
// Comparación con tipos explícitos
numeros.forEach((Integer n) -> System.out.println(n * n));
Ambas versiones son equivalentes, pero la primera es más concisa gracias a la inferencia de tipos.
Ambigüedad y resolución de tipos
En algunos casos, puede haber ambigüedad en el target type:
// Método sobrecargado
public void procesar(Consumer<String> consumidor) { /* ... */ }
public void procesar(Predicate<String> predicado) { /* ... */ }
// ¿Qué método se llama? ¡Ambigüedad!
procesar(s -> System.out.println(s)); // Error de compilación
En estos casos, es necesario proporcionar información adicional para resolver la ambigüedad:
// Soluciones a la ambigüedad
procesar((Consumer<String>) s -> System.out.println(s));
// O usar una referencia de método
procesar(System.out::println);
Contextos prácticos de uso
Las expresiones lambda se utilizan en diversos contextos prácticos:
- Operaciones con colecciones:
List<String> nombres = Arrays.asList("Ana", "Juan", "Carlos");
// Filtrado
List<String> nombresFiltrados = nombres.stream()
.filter(nombre -> nombre.length() > 3)
.collect(Collectors.toList());
- Eventos en interfaces gráficas:
JButton boton = new JButton("Pulsar");
boton.addActionListener(e -> System.out.println("Botón pulsado"));
- Programación concurrente:
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
// Código a ejecutar en otro hilo
return "Tarea completada";
});
- Operaciones de ordenación:
List<Persona> personas = obtenerPersonas();
// Ordenar por edad
Collections.sort(personas, (p1, p2) -> Integer.compare(p1.getEdad(), p2.getEdad()));
Conversión de contexto
El contexto de conversión es otro aspecto importante del target typing:
// Método que acepta un Runnable
void ejecutarEnHilo(Runnable tarea) {
new Thread(tarea).start();
}
// Llamada al método con una lambda
ejecutarEnHilo(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("Iteración " + i);
}
});
En este ejemplo, la lambda se convierte automáticamente al tipo Runnable
porque ese es el tipo esperado por el método.
El target typing es un mecanismo fundamental que permite que las expresiones lambda en Java sean concisas y expresivas, al tiempo que mantiene la seguridad de tipos característica del lenguaje. Entender cómo funciona este mecanismo es esencial para utilizar lambdas de manera efectiva en diferentes contextos.
Variables capturadas y scope
Las expresiones lambda en Java no solo pueden utilizar parámetros que reciben, sino que también pueden acceder a variables definidas en el contexto donde se declaran. Esta capacidad de "capturar" variables del entorno circundante es una característica fundamental que amplía significativamente la utilidad de las lambdas.
Captura de variables
Cuando una expresión lambda utiliza variables definidas fuera de su cuerpo, se dice que captura esas variables. Existen dos tipos de variables que pueden ser capturadas:
- Variables de instancia y estáticas: Accesibles sin restricciones
- Variables locales: Deben ser efectivamente finales
class EjemploCaptura {
// Variable de instancia
private int contador = 0;
public void incrementarContador() {
// La lambda captura la variable de instancia 'contador'
Runnable incrementador = () -> {
contador++; // Acceso permitido a variable de instancia
System.out.println("Contador: " + contador);
};
new Thread(incrementador).start();
}
}
Restricción de efectivamente final
Una característica importante de las lambdas en Java es la restricción de que las variables locales capturadas deben ser efectivamente finales. Una variable es efectivamente final cuando su valor no cambia después de su inicialización, aunque no esté explícitamente declarada como final
.
public void procesarDatos() {
String prefijo = "ID-"; // Variable local efectivamente final
// Correcto: la variable 'prefijo' no se modifica después de su inicialización
Consumer<String> procesador = dato -> System.out.println(prefijo + dato);
procesarItems(procesador);
// Si intentáramos esto, obtendríamos un error de compilación:
// prefijo = "CÓDIGO-"; // Error: Variable used in lambda should be final or effectively final
}
Esta restricción existe porque:
- Las lambdas pueden ejecutarse en contextos asíncronos (como hilos separados)
- Las variables locales se almacenan en la pila y podrían dejar de existir cuando el método termine
- Para evitar condiciones de carrera y comportamientos impredecibles
Diferencia con variables de instancia
A diferencia de las variables locales, las variables de instancia y estáticas pueden modificarse dentro de las lambdas sin restricciones:
class Contador {
private int valor = 0; // Variable de instancia
public void incrementar() {
// Podemos modificar la variable de instancia 'valor' dentro de la lambda
Runnable tarea = () -> {
for (int i = 0; i < 10; i++) {
valor++; // Permitido: modificación de variable de instancia
}
System.out.println("Valor final: " + valor);
};
new Thread(tarea).start();
}
}
Esto es posible porque las variables de instancia se almacenan en el heap (montículo) y no en la pila, por lo que siguen existiendo mientras el objeto exista.
Soluciones para variables mutables
Cuando necesitamos modificar un contador o acumulador dentro de una lambda, podemos usar estas alternativas:
- Usar variables de instancia como vimos anteriormente
- Utilizar clases contenedoras como
AtomicInteger
o arrays de un solo elemento
public void contarElementos(List<String> elementos) {
// Usando un array de un solo elemento como contenedor mutable
final int[] contador = {0};
elementos.forEach(elemento -> {
if (elemento.length() > 5) {
contador[0]++; // Modificamos el contenido del array, no la referencia
}
});
System.out.println("Elementos largos: " + contador[0]);
}
- Usar clases atómicas para operaciones thread-safe:
public void procesarConcurrentemente(List<String> datos) {
AtomicInteger contador = new AtomicInteger(0);
datos.parallelStream().forEach(dato -> {
// Procesamiento del dato
contador.incrementAndGet(); // Incremento atómico y thread-safe
});
System.out.println("Elementos procesados: " + contador.get());
}
Scope de las variables en lambdas
El ámbito o scope de las variables dentro de una lambda sigue reglas específicas:
- Las lambdas crean su propio ámbito para variables locales
- No pueden redeclarar variables que existen en el ámbito contenedor
- Pueden ocultar variables de ámbitos externos si declaran nuevas con el mismo nombre
public void ejemploScope() {
String mensaje = "Mensaje original";
// Correcto: la lambda captura 'mensaje' del ámbito externo
Runnable r1 = () -> System.out.println(mensaje);
// Error: no se puede redeclarar 'mensaje' como parámetro
// Consumer<String> c = mensaje -> System.out.println(mensaje);
// Correcto: la lambda declara su propia variable local 'i'
for (int i = 0; i < 5; i++) {
int valorFinal = i; // Variable efectivamente final para captura
Runnable r2 = () -> {
// int i = 10; // Error: no se puede redeclarar 'i'
System.out.println("Valor capturado: " + valorFinal);
};
r2.run();
}
}
La palabra clave 'this' en lambdas
El comportamiento de la palabra clave this
dentro de las lambdas es diferente al de las clases anónimas:
- En una lambda,
this
se refiere a la instancia de la clase contenedora - En una clase anónima,
this
se refiere a la instancia de la clase anónima
class DemostradorThis {
private String nombre = "Instancia contenedora";
public void mostrarThis() {
// En la lambda, 'this' se refiere a la instancia de DemostradorThis
Runnable lambda = () -> {
System.out.println("En lambda, this.nombre = " + this.nombre);
};
// En la clase anónima, 'this' se refiere a la instancia de Runnable
Runnable anonima = new Runnable() {
private String nombre = "Clase anónima";
@Override
public void run() {
System.out.println("En anónima, this.nombre = " + this.nombre);
// Para acceder a la instancia externa:
System.out.println("Instancia externa: " + DemostradorThis.this.nombre);
}
};
lambda.run(); // Imprime: "En lambda, this.nombre = Instancia contenedora"
anonima.run(); // Imprime: "En anónima, this.nombre = Clase anónima"
}
}
Esta diferencia hace que las lambdas sean más intuitivas cuando se trabaja con el contexto de la clase contenedora.
Ejemplos prácticos de captura de variables
Veamos algunos ejemplos prácticos de cómo se utilizan las variables capturadas:
- Configuración de comportamiento:
public List<String> filtrarPorPrefijo(List<String> elementos, String prefijo) {
// La lambda captura la variable 'prefijo'
return elementos.stream()
.filter(elemento -> elemento.startsWith(prefijo))
.collect(Collectors.toList());
}
- Acumulación de resultados:
public Map<String, Integer> contarPalabras(List<String> textos) {
Map<String, Integer> frecuencias = new HashMap<>();
// La lambda captura la variable 'frecuencias'
textos.forEach(texto -> {
String[] palabras = texto.split("\\s+");
for (String palabra : palabras) {
frecuencias.merge(palabra.toLowerCase(), 1, Integer::sum);
}
});
return frecuencias;
}
- Personalización de comportamiento:
public void procesarConTimeout(Runnable tarea, long timeoutMs) {
// La lambda captura las variables 'tarea' y 'timeoutMs'
Thread hilo = new Thread(() -> {
long inicio = System.currentTimeMillis();
tarea.run();
long duracion = System.currentTimeMillis() - inicio;
if (duracion > timeoutMs) {
System.out.println("Advertencia: la tarea excedió el timeout de " + timeoutMs + "ms");
}
});
hilo.start();
}
La capacidad de capturar variables del entorno hace que las expresiones lambda sean extremadamente versátiles, permitiendo crear funciones que se adaptan dinámicamente al contexto donde se utilizan, lo que facilita la implementación de patrones de programación funcional en Java.
Aprendizajes de esta lección
- Comprender la sintaxis básica y variaciones de las expresiones lambda en Java.
- Identificar los contextos de uso y el mecanismo de target typing para lambdas.
- Entender cómo las lambdas capturan variables externas y las restricciones asociadas.
- Diferenciar el scope de variables dentro de lambdas y el comportamiento de la palabra clave 'this'.
- Aplicar expresiones lambda para simplificar código en colecciones, concurrencia y eventos.
Completa Java y certifícate
Únete a nuestra plataforma y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.
Asistente IA
Resuelve dudas al instante
Ejercicios
Practica con proyectos reales
Certificados
Valida tus conocimientos
Más de 25.000 desarrolladores ya se han certificado con CertiDevs