1. Método forEach: acción final sobre los elementos
Antes de entrar en detalles, recordemos qué son las operaciones terminales e intermedias de un stream.
- Operaciones intermedias (por ejemplo, filter, map, distinct, peek) — devuelven un nuevo stream y, por lo general, no realizan acciones hasta que se llama a una operación terminal.
- Operaciones terminales (por ejemplo, forEach, collect, count, anyMatch) — ponen en marcha el procesamiento de los elementos del stream y devuelven un resultado (o no devuelven nada, como forEach).
Tras una operación terminal, el stream se considera cerrado y ya no se le pueden aplicar otras operaciones. Es como intentar terminarse un helado que ya te has comido: no va a funcionar; el stream ya está «usado».
Ahora conozcamos dos métodos importantes para trabajar con efectos secundarios: forEach y peek.
¿Qué hace forEach?
forEach es una operación terminal del stream que ejecuta la acción indicada para cada elemento del stream. Suele usarse para mostrar por pantalla, escribir en el log, calcular estadísticas y otros efectos secundarios (side effects).
Firma del método:
void forEach(Consumer<? super T> action)
Consumer<T> es una interfaz funcional que acepta un argumento y no devuelve nada (por ejemplo, System.out::println).
Ejemplo: imprimir todos los elementos de la lista
Supongamos que tenemos una lista de nombres de usuarios:
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
Mostrar a todos los usuarios por pantalla con la Stream API es muy sencillo:
users.stream().forEach(System.out::println);
Resultado:
Anna
Boris
Alex
Alina
Dmitry
También puedes usar una expresión lambda:
users.stream().forEach(name -> System.out.println("Usuario: " + name));
Resultado:
Usuario: Anna
Usuario: Boris
Usuario: Alex
Usuario: Alina
Usuario: Dmitry
Importante: forEach cierra el stream
Después de llamar a forEach, el stream «se cierra». No se puede continuar la cadena:
users.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println)
.map(String::toUpperCase); // ¡Error! El stream ya está cerrado.
Intentar invocar algo después de forEach provocará un error de compilación: la operación terminal devuelve void, no un nuevo stream.
2. Método peek: miramos, pero no intervenimos
peek es una operación intermedia. Permite ejecutar una acción para cada elemento en una etapa determinada del procesamiento, sin cambiar el propio elemento y sin cerrar el stream.
Firma del método:
Stream<T> peek(Consumer<? super T> action)
- peek devuelve un nuevo stream en el que, para cada elemento, se ejecutará la acción action.
- Suele usarse para depuración, registro (logging) o monitorización del estado del stream.
Ejemplo: registro (logging) después del filtrado
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
List<Integer> nameLengths = users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Pasó el filtro: " + name))
.map(String::length)
.collect(Collectors.toList());
Resultado en la consola:
Pasó el filtro: Anna
Pasó el filtro: Alex
Pasó el filtro: Alina
Contenido de nameLengths:
[4, 4, 5]
¿Dónde es útil usar peek?
- Para depurar la cadena de operaciones: ver qué ocurre en cada etapa.
- Para recopilar estadísticas (por ejemplo, el recuento de elementos).
- Para registrar datos en etapas intermedias.
Importante: peek no debe usarse para modificar los elementos del stream. Para transformaciones está map. peek es «mirar», no «intervenir».
3. forEach vs peek: ¿en qué se diferencian?
| Método | Tipo de operación | Cuándo se aplica | ¿Se puede continuar la cadena? | Para qué sirve mejor |
|---|---|---|---|---|
|
Terminal | Al final del procesamiento del stream | No | Acciones finales (impresión, registro, escritura en la BD) |
|
Intermedia | En medio de la cadena de operaciones | Sí | Depuración, registro intermedio, recuento |
Ejemplo: diferencia en el uso
// Ejemplo con forEach
users.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println); // Aquí el stream se cierra
// Ejemplo con peek
users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("Pasó el filtro: " + name))
.map(String::toUpperCase)
.collect(Collectors.toList()); // Se puede continuar la cadena
Es importante recordar
- forEach es un punto sin retorno: después de él ya no se puede hacer nada más con el stream.
- peek no garantiza la ejecución de las acciones si no se invoca una operación terminal. Si escribes solo una cadena de operaciones intermedias, no ocurrirá nada.
4. Aspectos no evidentes: ¡forEach no siempre es la mejor opción!
¿Por qué no conviene usar forEach para modificar colecciones?
Muchos principiantes intentan usar forEach para modificar los elementos de una colección o la colección misma (por ejemplo, eliminar elementos). Pero es una mala práctica: los streams no están pensados para modificar las colecciones de origen.
Ejemplo de uso incorrecto:
List<String> names = new ArrayList<>(List.of("Anna", "Boris", "Alex"));
names.stream().forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // ¡Puede provocar ConcurrentModificationException!
}
});
Resultado: error en tiempo de ejecución — no se puede modificar la colección mientras se recorre mediante un stream (ConcurrentModificationException).
Entonces, ¿para qué usar forEach?
- Para imprimir por pantalla (por ejemplo, generar un informe).
- Para registrar en logs.
- Para llamar a servicios externos (por ejemplo, enviar un correo electrónico).
- Para recopilar estadísticas (por ejemplo, incrementar un contador).
5. Una vez más sobre peek: ¡solo para depuración!
Es tentador usar peek para modificar elementos, por ejemplo, aumentar la edad de un usuario:
users.stream()
.peek(user -> user.setAge(user.getAge() + 1)) // ¡Mala idea!
.collect(Collectors.toList());
¿Por qué es mala idea?
- Rompe la declaratividad y la pureza de la Stream API.
- Ese código se vuelve difícil de mantener y de probar.
- Los efectos secundarios en una operación intermedia pueden conducir a errores poco evidentes.
Es mejor usar map para transformar los datos:
List<User> olderUsers = users.stream()
.map(user -> new User(user.getName(), user.getAge() + 1))
.collect(Collectors.toList());
Esquema: diferencia entre forEach y peek
users.stream()
.filter(...) // operación intermedia
.peek(...) // operación intermedia, "miramos"
.map(...) // operación intermedia
.forEach(...) // operación terminal, "hacemos la acción"
Explicación:
— Todo lo que va antes de forEach se puede combinar, reordenar, añadir.
— Después de forEach el stream está cerrado.
6. Errores típicos al trabajar con forEach y peek
Error n.º 1: usar peek para modificar datos. peek está pensado solo para observar, no para cambiar los elementos del stream. Para transformaciones, utiliza map.
Error n.º 2: esperar que peek se ejecute siempre. peek se ejecuta solo si después hay una operación terminal (collect, forEach, count, etc.). Sin una operación terminal, no pasará nada.
Error n.º 3: intentar continuar el stream después de forEach. forEach es una operación terminal. Tras ella no se pueden invocar más métodos del stream.
Error n.º 4: modificar la colección dentro de forEach. Cambiar la colección de origen (eliminar o añadir elementos) durante el recorrido mediante forEach es un camino directo a ConcurrentModificationException.
Error n.º 5: usar forEach en lugar de collect para recopilar el resultado. Si quieres reunir los elementos en una nueva colección, usa collect(Collectors.toList()), y no forEach con adición manual. Esto rompe la declaratividad y puede provocar errores en escenarios concurrentes.
GO TO FULL VERSION