En .NET, es bastante habitual que nuestros métodos o funciones reciban objetos de tipo IEnumerable<T> con la intención de que iteremos sobre ellos para lograr el comportamiento deseado. Esta abstracción es muy poderosa, ya que nos permite trabajar con cualquier colección o secuencia de datos sin preocuparnos por su implementación concreta, pero, si no somos cuidadosos, puede llevarnos a errores y comportamientos inesperados.
Por ejemplo, es muy frecuente realizar múltiples enumeraciones sobre la misma instancia de IEnumerable<T>, algo que a veces puede ocurrir de forma inconsciente, y se convierte en un problema cuando la secuencia es muy extensa o su contenido no es replicable. De hecho, muchas herramientas y entornos de desarrollo nos advierten sobre este problema durante la edición y compilación para que estemos atentos.
Otro problema común es asumir que la secuencia es finita, algo que no tiene por qué ser siempre cierto. Las enumeraciones en este caso pueden llevar a bucles infinitos o a un consumo excesivo de recursos.
En este artículo exploraremos distintos escenarios que pueden darse y cómo podemos evitarlos.
¿Qué es una enumeración múltiple?
Una enumeración múltiple ocurre cuando un método o función recorre una secuencia de datos más de una vez. Esto puede suceder de forma explícita, por ejemplo, si tenemos dos bucles foreach que iteran sobre la misma secuencia, o de forma implícita, como cuando llamamos a varios métodos como Count(), ToList(), Last(), etc. sobre la misma colección.
Cuando se trata de una colección en memoria, como una lista o un array, no suele haber problema al realizar enumeraciones múltiples, ya que estas estructuras permiten recorrerlas tantas veces como sea necesario sin incurrir en un coste significativo.
Sin embargo, si la secuencia es generada dinámicamente, como una consulta LINQ a una base de datos o aquellas producidas por un iterador, cada operación de enumeración puede implicar un coste considerable, tanto en términos de rendimiento como de recursos, lo que hace que la enumeración múltiple pueda ser problemática.
Pero además, el contenido de la secuencia puede variar entre distintas enumeraciones, lo que puede llevar a resultados inconsistentes o errores inesperados. Es lo que llamamos "secuencias no replicables".
¿Qué es una secuencia no replicable?
Una secuencia no replicable es aquella que no puede ser recorrida múltiples veces de manera segura. Un ejemplo típico es una consulta LINQ que se ejecuta contra una base de datos, datos procedentes de un stream o una secuencia generada por un iterador que produce valores bajo demanda. En ninguno de esos casos podemos garantizar que la secuencia se mantenga igual entre diferentes enumeraciones, o incluso que esté disponible para una segunda enumeración (por ejemplo, los datos podrían haber cambiado en la base de datos).
Por ejemplo, observad una función como la siguiente, que recibe un objeto IEnumerable<int>, muestra el número de elementos recibidos e itera dos veces sobre ellos para procesarlos:
static void Process(IEnumerable<int> numbers)
{
Console.WriteLine($"Processing {numbers.Count()} numbers");
Console.WriteLine("First pass");
foreach (var number in numbers)
{
Console.WriteLine($" Doing something with {number}");
DoSomething(number);
}
Console.WriteLine("Second pass");
foreach (var number in numbers)
{
Console.WriteLine($" Doing another thing with {number}");
DoAnotherThing(number);
}
}
Si el objeto enviado a la función Process() se encontraba ya en memoria, por ejemplo en forma de una lista (List<int>) o array (int[]), el código anterior funcionará sin problemas. La ejecución será consistente y predecible, los elementos se contarán de forma correcta y ambos bucles foreach los recorrerán, por lo que la salida en consola será idéntica en las dos vueltas.
Process([1, 2, 3]);
Processing 3 numbers
First pass
Doing something with 1
Doing something with 2
Doing something with 3
Second pass
Doing another thing with 1
Doing another thing with 2
Doing another thing with 3
Sin embargo, imaginad que lo que enviamos es una consulta LINQ que se ejecuta contra una base de datos. En ese caso, la llamada a Count() ejecutará la consulta para obtener el número de elementos. Tras ello, el primer foreach ejecutará de nuevo la consulta y obtendrá los resultados, y el segundo foreach volverá a ejecutarla. Esto, además de suponer potencialmente un problema de rendimiento, puede devolver en cada caso un conjunto diferente de resultados o incluso lanzar una excepción si la conexión a la base de datos ya no está disponible.
Lo mismo puede ocurrir si la secuencia es generada por un iterador que produce valores bajo demanda, como en el siguiente ejemplo. Como podéis ver, se trata de una función generadora que devuelve entre 3 y 7 números aleatorios en el rango 1-9 cada vez que se itera sobre ella:
static IEnumerable<int> GenerateNumbers()
{
for (int i = 0; i < Random.Shared.Next(3, 8); i++)
{
yield return Random.Shared.Next(1, 10);
}
}
En este caso, cada llamada a GenerateNumbers() produce una secuencia diferente de números. Por lo tanto, si pasamos esta función generadora a nuestro método Process(), veremos que cada vez que enumeramos la secuencia obtenemos resultados distintos, tanto en el número de elementos como en los propios valores:
Process(GenerateNumbers());
Processing 2 numbers
First pass
Doing something with 9
Doing something with 8
Doing something with 2
Doing something with 3
Doing something with 4
Doing something with 9
Doing something with 8
Second pass
Doing another thing with 5
Doing another thing with 3
Doing another thing with 2
Observad que, en este caso, cada enumeración de la secuencia ha producido un conjunto diferente de números, lo que puede llevar a comportamientos inesperados en nuestro código, como el que vemos en la salida anterior.
Por tanto, cuando escribimos un método o función que acepta parámetros de tipo IEnumerable<T>, debemos ser conscientes de que estamos abriendo la puerta a que un consumidor realice invocaciones enviando secuencias no replicables.
¿Qué son las secuencias infinitas?
Otra situación problemática es cuando la secuencia recibida es infinita. Esto puede ocurrir, por ejemplo, si la secuencia es generada por un iterador que produce valores de forma indefinida, como la siguiente:
static IEnumerable<int> InfiniteNumbers()
{
int i = 0;
while (true)
{
yield return (i++) % 10;
}
}
Esta secuencia generará números indefinidamente, repitiendo los dígitos del 0 al 9, por lo que cualquier intento de recorrerla por completo derivará en un bucle infinito. De hecho, si pasamos esta secuencia a nuestro método Process(), la llamada a Count() intentará contar todos los elementos y dará lugar a un bloqueo de la aplicación.
¿Qué podemos hacer para evitar estos problemas?
Afortunadamente, hay soluciones sencillas para evitar los comportamientos inesperados cuando trabajamos con parámetros de tipo IEnumerable<T>.
La opción más recomendable sería intentar refactorizar el método para que no sea necesario realizar más de una enumeración de la secuencia recibida. En el caso anterior es complicado porque el Count() inicial ya implica una enumeración, pero quizás funcionalmente podría ser prescindible o sustituible por otro enfoque similar:
static void Process(IEnumerable<int> numbers)
{
var count = 0;
foreach (var number in numbers)
{
count++;
Console.WriteLine($" Doing something with {number}");
DoSomething(number);
Console.WriteLine($" Doing another thing with {number}");
DoAnotherThing(number);
}
Console.WriteLine($"{count} numbers processed");
}
Sin duda esta es la mejor opción porque evita todos los problemas de raíz, sin sacrificar rendimiento ni la flexibilidad de recibir un objeto de tipo IEnumerable<T>. Además, permitiría detener la enumeración en cualquier momento, cuando ya no sea necesario procesar más elementos, sin necesidad de recorrer la secuencia completa.
Sin embargo, no siempre será posible.
Otra opción es bajar el nivel de abstracción y cambiar el tipo de datos del parámetro, para asegurar que solo se puedan enviar colecciones que ya estén materializadas en memoria y que, por tanto, sean replicables. Por ejemplo, podríamos cambiar la firma del método para recibir objetos IReadOnlyCollection<T> o IReadOnlyList<T>, lo que garantiza que la colección es fija y puede ser recorrida múltiples veces sin problemas.
La diferencia principal entre
IReadOnlyCollection<T>eIReadOnlyList<T>es que la primera solo garantiza que la colección tiene un tamaño definido y puede ser contada, mientras que la segunda también permite acceder a los elementos por índice. En este caso, cualquiera de las dos opciones sería válida.
static void Process(IReadOnlyCollection<int> numbers)
{
... // El resto del código permanece igual
}
A veces, esto requerirá que los consumidores del método tengan que adaptar su código para enviar colecciones materializadas, pero a cambio evitamos cualquier riesgo de múltiples enumeraciones inesperadas. También estaremos protegiéndonos contra secuencias infinitas, ya que ningún consumidor podrá enviar una secuencia que no tenga un tamaño definido.
// Código del consumidor
var items = GenerateNumbers().ToList(); // Materializamos la secuencia
Process(items);
Si ninguna de las opciones anteriores es viable, siempre podemos optar por materializar la secuencia recibida al inicio del método, almacenándola en una lista o array. De esta forma, nos aseguramos de que cualquier enumeración posterior se realice sobre una colección fija y replicable.
static void Process(IEnumerable<int> numbers)
{
var materializedNumbers = numbers.ToList(); // Materializamos la secuencia
... // El resto del código permanece igual, pero usando materializedNumbers
}
El inconveniente de esto es que, si la secuencia original es muy grande, podríamos estar consumiendo una cantidad significativa de memoria. Tampoco tendremos protección contra secuencias infinitas, ya que la llamada a ToList() intentará recorrer toda la secuencia para materializarla.
Conclusión
En resumen, cuando trabajamos con parámetros de tipo IEnumerable<T>, debemos ser conscientes de los posibles problemas que pueden surgir debido a múltiples enumeraciones o secuencias infinitas. Adoptar buenas prácticas y elegir la estrategia adecuada según el contexto nos ayudará a evitar errores y a escribir código más robusto y predecible.


Aún no hay comentarios, ¡sé el primero!
Enviar un nuevo comentario