Introducción
En Java, existen varias opciones para iterar sobre una colección. En este breve tutorial, analizaremos dos enfoques que se ven similares: Collection.stream().forEach()
y Collection.forEach()
. En la mayoría de los casos, ambos producirán los mismos resultados, pero exploraremos algunas diferencias sutiles que pueden influir en el flujo de nuestros programas.
1. Una Lista Simple
Comencemos creando una lista sobre la cual iterar:
List<String> list = Arrays.asList("A", "B", "C", "D");
La forma más directa de iterar sobre la lista es utilizando el bucle for mejorado:
for(String s : list) {
// hacer algo con s
}
Si queremos adoptar un estilo funcional en Java, también podemos utilizar el método forEach()
.
Podemos hacerlo directamente sobre la colección:
Consumer<String> consumer = s -> { System.out.println(s); };
list.forEach(consumer);
O podemos llamar a forEach()
sobre el stream de la colección:
list.stream().forEach(consumer);
Ambas versiones iterarán sobre la lista y imprimirán todos los elementos:
ABCD ABCD
En este caso simple, no hay diferencia entre cuál de los dos forEach()
utilizamos.
2. Orden de Ejecución
Collection.forEach()
utiliza el iterador de la colección (si se especifica uno), por lo que el orden de procesamiento de los elementos está definido. En contraste, el orden de procesamiento de Collection.stream().forEach()
es indefinido.
En la mayoría de los casos, esto no tendrá un impacto significativo en la forma en que elegimos entre los dos enfoques.
2.1. Streams Paralelos
Los streams paralelos nos permiten ejecutar el stream en múltiples hilos, y en tales situaciones, el orden de ejecución es indefinido. Java solo requiere que todos los hilos terminen antes de que se llame a cualquier operación terminal, como Collectors.toList()
.
Veamos un ejemplo donde primero llamamos a forEach()
directamente en la colección y, en segundo lugar, en un stream paralelo:
list.forEach(System.out::print);
System.out.print(" ");
list.parallelStream().forEach(System.out::print);
Si ejecutamos el código varias veces, veremos que list.forEach()
procesa los elementos en el orden de inserción, mientras que list.parallelStream().forEach()
produce un resultado diferente en cada ejecución.
Aquí hay una posible salida:
ABCD CDBA
Y esta es otra:
ABCD DBCA
2.2. Iteradores Personalizados
Definamos una lista con un iterador personalizado para iterar sobre la colección en orden inverso:
class ReverseList extends ArrayList<String> {
@Override
public Iterator<String> iterator() {
int startIndex = this.size() - 1;
List<String> list = this;
Iterator<String> it = new Iterator<String>() {
private int currentIndex = startIndex;
@Override
public boolean hasNext() {
return currentIndex >= 0;
}
@Override
public String next() {
String next = list.get(currentIndex);
currentIndex--;
return next;
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
return it;
}
}
Luego iteraremos sobre la lista nuevamente con forEach()
directamente en la colección y luego en el stream:
List<String> myList = new ReverseList();
myList.addAll(list);
myList.forEach(System.out::print);
System.out.print(" ");
myList.stream().forEach(System.out::print);
Y obtendremos resultados diferentes:
DCBA ABCD
La razón de los diferentes resultados es que forEach()
utilizado directamente en la lista usa el iterador personalizado, mientras que stream().forEach()
simplemente toma los elementos uno por uno de la lista, ignorando el iterador.
3. Modificación de la Colección
Muchas colecciones, como ArrayList
o HashSet
, no deben ser modificadas estructuralmente mientras se itera sobre ellas. Si se añade o se elimina un elemento durante una iteración, obtendremos una excepción de ConcurrentModificationException
.
Además, las colecciones están diseñadas para fallar rápidamente, lo que significa que la excepción se lanzará tan pronto como haya una modificación.
De manera similar, también obtendremos una ConcurrentModificationException
cuando añadamos o eliminemos un elemento durante la ejecución de la tubería del stream. Sin embargo, la excepción se lanzará más tarde.
Otra diferencia sutil entre los dos métodos forEach()
es que Java permite explícitamente modificar elementos utilizando el iterador. En cambio, los streams deben ser no interfiriendo.
3.1. Eliminar un Elemento
Definamos una operación que elimine el último elemento (“D”) de nuestra lista:
Consumer<String> removeElement = s -> {
System.out.println(s + " " + list.size());
if (s != null && s.equals("A")) {
list.remove("D");
}
};
Cuando iteramos sobre la lista, el último elemento se elimina después de que se imprime el primer elemento (“A”):
list.forEach(removeElement);
Dado que forEach()
es fallar-rápido, dejamos de iterar y vemos una excepción antes de que se procese el siguiente elemento:
A 4
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList.forEach(ArrayList.java:1252)
at ReverseList.main(ReverseList.java:1
Veamos qué sucede si usamos stream().forEach()
en su lugar:
list.stream().forEach(removeElement);
Aquí continuamos iterando sobre toda la lista antes de ver una excepción:
A 4
B 3
C 3
null 3
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1380)
at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:580)
at ReverseList.main(ReverseList.java:1
Sin embargo, Java no garantiza que se lance una ConcurrentModificationException en absoluto. Eso significa que nunca debemos escribir un programa que dependa de esta excepción.
3.2. Cambiar Elementos
Podemos cambiar un elemento al iterar sobre una lista:
list.forEach(e -> {
list.set(3, "E");
});
Pero si bien no hay problema en hacer esto utilizando Collection.forEach()
o stream().forEach()
, Java requiere que una operación en un stream sea no interfiriendo. Esto significa que los elementos no deben modificarse durante la ejecución de la tubería del stream.
La razón detrás de esto es que el stream debe facilitar la ejecución paralela. Aquí, modificar elementos de un stream podría llevar a un comportamiento inesperado.
4. Conclusión
En este artículo, observamos algunos ejemplos que muestran las sutiles diferencias entre Collection.forEach()
y Collection.stream().forEach()
.
Es importante notar que todos los ejemplos presentados son triviales y están destinados únicamente a comparar las dos maneras de iterar sobre una colección. No debemos escribir código cuya corrección dependa del comportamiento mostrado.
Si no requerimos un stream y solo queremos iterar sobre una colección, la primera opción debe ser usar forEach()
directamente en la colección. Así garantizamos la claridad y mantenibilidad de nuestro código en Java.
Además, considera siempre las implicaciones de la concurrencia y la modificación de colecciones, ya que esto puede afectar el comportamiento de tus programas de maneras inesperadas.