Funciones lambda

Intermedio
Java
Java
Actualizado: 08/05/2025

¡Desbloquea el curso completo!

IA
Ejercicios
Certificado
Entrar

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 o continue 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.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

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:

  1. Las lambdas pueden ejecutarse en contextos asíncronos (como hilos separados)
  2. Las variables locales se almacenan en la pila y podrían dejar de existir cuando el método termine
  3. 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

⭐⭐⭐⭐⭐
4.9/5 valoración