paint-brush
IEnumerable no es lo que crees que es y está rompiendo tu códigopor@dmitriislabko
Nueva Historia

IEnumerable no es lo que crees que es y está rompiendo tu código

por Dmitrii Slabko14m2025/02/18
Read on Terminal Reader

Demasiado Largo; Para Leer

Repasemos en detalle el error más común en relación con IEnumerable - enumeración repetida - pero esta vez profundizaremos un poco más y revisaremos por qué la enumeración repetida es un error y qué problemas potenciales puede causar, incluidos errores difíciles de detectar y reproducir.
featured image - IEnumerable no es lo que crees que es y está rompiendo tu código
Dmitrii Slabko HackerNoon profile picture
0-item

TLDR: Repasemos en detalle el error más común en relación con IEnumerable - enumeración repetida - pero esta vez profundizaremos un poco más y revisaremos por qué la enumeración repetida es un error y qué problemas potenciales puede causar, incluidos errores difíciles de detectar y reproducir.

¿Qué es IEnumerable?

Lo primero es lo primero: repasemos (una vez más, ya que hay muchos artículos sobre esto) qué es IEnumerable, tanto genérico como no genérico. Muchos desarrolladores, como muestran muchas entrevistas y revisiones de código, ven sin darse cuenta las instancias de IEnumerable como colecciones, y aquí es donde comenzaremos.


Cuando observamos la definición de la interfaz de IEnumerable, esto es lo que vemos:

 public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }


No entraremos en detalles sobre enumeradores y demás; basta con señalar algo muy importante: IEnumerable no es una colección. La mayoría de los tipos de colección implementan IEnumerable, pero eso no convierte todas las implementaciones de IEnumerable en colecciones. Sorprendentemente, esto es lo que muchos desarrolladores pasan por alto cuando implementan código que consume o produce IEnumerable, y eso es lo que tiene un gran potencial de problemas.


Entonces, ¿qué es IEnumerable? Hay muchas implementaciones diferentes para IEnumerable, pero para simplificar podemos resumirlas en una definición (bastante vaga): es un fragmento de código que produce elementos en iteración. Para colecciones en memoria, este código simplemente leería el elemento actual de la colección subyacente y movería su puntero interno al siguiente elemento, si existe. Para casos más sofisticados, la lógica puede ser muy variada y puede tener todo tipo de efectos secundarios que también pueden incluir la modificación del estado compartido o depender del estado compartido.


Ahora tenemos una idea un poco mejor de lo que es IEnumerable, y eso nos indica que debemos implementar el código de consumo de una manera que no debería hacer suposiciones sobre estos puntos:

  • el coste necesario para producir artículos, es decir, si un artículo se recuperó de algún tipo de almacenamiento (se reutilizó) o se creó;
  • el mismo artículo puede volver a producirse en iteraciones posteriores;
  • cualquier posible efecto secundario que pudiera afectar (o no) a iteraciones posteriores.


Como podemos ver, esto es casi lo opuesto a las convenciones generales cuando se itera sobre colecciones en memoria, por ejemplo:

  • una colección no se puede modificar durante una iteración: si se modifica una colección, esto provocará una excepción al pasar al siguiente elemento de la colección;
  • Iterar sobre la misma colección (que contiene los mismos elementos) siempre producirá los mismos resultados y siempre tendrá los mismos costos.


Una forma segura de considerar a IEnumerable es percibirlo como un "productor de datos a pedido". La única garantía que ofrece este productor de datos es que obtendrá otro elemento o señalará que no hay más elementos disponibles cuando se lo llame. Todo lo demás son detalles de implementación de un productor de datos en particular. Por cierto, aquí describimos el contrato de la interfaz IEnumerator que permite iterar sobre una instancia de IEnumerable.


Otro aspecto importante del productor de datos bajo demanda es que produce un elemento por iteración y el código consumidor puede decidir si desea agotar todo lo que el productor es capaz de producir o detener el consumo antes. Dado que el productor de datos bajo demanda ni siquiera ha intentado trabajar en ningún elemento "futuro" potencial, esto permite ahorrar recursos cuando el consumo finaliza prematuramente.


Por lo tanto, al implementar productores IEnumerable, nunca debemos hacer suposiciones sobre los patrones de consumo. Los consumidores pueden iniciar y detener el consumo en cualquier momento.

Efectos potenciales de iteraciones repetidas.

Ahora que hemos definido la forma correcta de consumir IEnumerable, revisemos algunos ejemplos de iteraciones repetidas y su impacto potencial.


Antes de pasar a los ejemplos negativos, vale la pena mencionar que cuando IEnumerable se hace pasar por una colección en memoria (matriz, lista, conjunto hash, etc.), no hay ningún daño en las iteraciones repetidas per se. El código que consume IEnumerable sobre colecciones en memoria en la mayoría de los casos se ejecutaría (casi) tan eficientemente como el código que consume tipos de colección coincidentes. Por supuesto, puede haber diferencias en ciertos casos, aunque no necesariamente negativas, ya que Linq ha experimentado muchas mejoras de rendimiento importantes que permitirían, por ejemplo, usar instrucciones de CPU vectorizadas para colecciones en memoria o compactar múltiples llamadas de métodos de interfaz en una para expresiones Linq complejas. Lea estos artículos para obtener más detalles: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linq y https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq


Sin embargo, desde el punto de vista de la calidad del código, tener múltiples iteraciones en IEnumerable se considera una mala práctica, ya que nunca podemos estar seguros de qué implementación concreta llegaría bajo la cubierta.

Nota al margen: dado que IEnumerable es una interfaz, su uso en lugar de tipos concretos obliga al compilador a emitir llamadas a métodos virtuales (la instrucción IL 'callvirt'), incluso cuando una clase subyacente concreta implementa este método como no virtual, por lo que una llamada a un método no virtual sería suficiente. Las llamadas a métodos virtuales son más costosas, ya que siempre deben pasar por la tabla de métodos de instancia para resolver la dirección del método; además, evitan la posible inserción en línea de métodos. Si bien esto puede considerarse una microoptimización, hay muchas rutas de código que mostrarían métricas de rendimiento diferentes si se usaran tipos concretos en lugar de interfaces.

Cuando una iteración repetida es realmente una mala elección.

Una pequeña exención de responsabilidad: este ejemplo se basa en un fragmento de código de la vida real que ha sido anonimizado y del que se han eliminado todos los detalles de implementación reales.

Este fragmento de código recuperaba datos de un punto final remoto para la lista de parámetros entrantes.

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }

¿Qué puede salir mal aquí? Repasemos el ejemplo más sencillo:

 var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();


Muchos desarrolladores considerarían que este código es seguro, ya que evita iteraciones repetidas al materializar la salida del método en una colección en memoria. Pero ¿lo es?


Antes de entrar en detalles, hagamos una prueba. Podemos utilizar una implementación de "externalService" muy simple para realizar la prueba:

 record Data(int Value); class Service { private static int counter = 0; public async Task<IData> QueryForDataAsync(int id, CancellationToken ct) { var timestamp = Stopwatch.GetTimestamp(); await Task.Delay(TimeSpan.FromMilliseconds(30), ct); int cv = Interlocked.Increment(ref counter); Console.WriteLine($"QueryForData - id={id} - {cv}; took {Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds:F0} ms"); return new Data(id); } }


Luego podemos ejecutar la prueba:

 var externalService = new Service(); var results = (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList(); Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } Console.WriteLine($"Getting the count again: {results.Count()}");


Y obtenemos el resultado:

 QueryForData - id=3 - 1; took 41 ms QueryForData - id=1 - 3; took 43 ms QueryForData - id=2 - 2; took 42 ms QueryForData - id=1 - 4; took 33 ms QueryForData - id=2 - 5; took 30 ms QueryForData - id=3 - 6; took 31 ms Querying completed Retrieved 3 elements Getting the count again: 3


Aquí hay algo que no cuadra, ¿no? Esperábamos obtener el resultado "QueryForData" solo 3 veces, ya que solo tenemos 3 identificadores en el argumento de entrada. Sin embargo, el resultado muestra claramente que la cantidad de ejecuciones se duplicó incluso antes de que se completara la llamada ToList().


Para entender el por qué, veamos el método RetrieveAndProcessDataAsync:

 1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);


Y echemos un vistazo a esta llamada:

 (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();


Cuando se llama al método RetrieveAndProcessDataAsync, suceden las siguientes cosas.


En la línea 1 obtenemos una instancia IEnumerable<Task<Data>> ; en nuestro caso, serían 3 tareas, ya que enviamos una matriz de entrada con 3 elementos. Cada tarea se pone en cola en el grupo de subprocesos para su ejecución y, tan pronto como hay un subproceso disponible, se inicia. El punto exacto de finalización de estas tareas no se determina debido a las particularidades de la programación del grupo de subprocesos y al hardware concreto en el que se ejecutaría este código.


En la línea 2, la llamada Task.WhenAll se asegura de que todas las tareas de la instancia IEnumerable<Task<Data>> hayan llegado a su finalización; básicamente, en este punto obtenemos las primeras 3 salidas del método QueryForDataAsync. Cuando se completa la línea 2, podemos estar seguros de que las 3 tareas también se han completado.


Sin embargo, la línea 3 es donde todos los demonios pusieron una emboscada. Vamos a desenterrarlos.


La variable 'retrievalTasks' (en la línea 1) es una instancia IEnumerable<Task<Data>> . Ahora, retrocedamos un paso y recordemos que IEnumerable no es otra cosa que un productor: un fragmento de código que produce (crea o reutiliza) instancias de un tipo determinado. En este caso, la variable 'retrievalTasks' es un fragmento de código que haría lo siguiente:


  • revisar la colección 'ids';
  • para cada elemento de esta colección, se llamaría al método externalService.QueryForDataAsync;
  • devuelve una instancia de tarea producida por la llamada anterior.


Podemos expresar toda esta lógica detrás de nuestra instancia IEnumerable<Task<Data>> de forma ligeramente diferente. Tenga en cuenta que, si bien este fragmento de código parece bastante distinto de la expresión ids.Select(id => externalService.QueryForDataAsync(id, ct)) original, hace exactamente lo mismo.


 IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }


Por lo tanto, podemos tratar la variable 'retrievalTasks' como una llamada a una función con un conjunto constante de entradas predefinidas. Esta función se llamaría cada vez que resolviéramos el valor de la variable. Podemos reescribir el método RetrieveAndProcessDataAsync de una manera que refleje completamente esta idea y que funcione de manera absolutamente igual a la implementación inicial:


 async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); return retrievalFunc().Select(t => t.Result); }


Ahora podemos ver muy claramente por qué se duplicó la salida de nuestro código de prueba: la función 'retrievalFunc' se llama dos veces... Si nuestro código consumidor continúa recorriendo la misma instancia de IEnumerable, equivaldría a llamadas repetidas a un método 'DataProducer', que ejecutaría su lógica una y otra vez para cada iteración.


Espero que ahora la lógica detrás de las iteraciones repetidas de IEnumerable esté clara.

Otras posibles implicaciones de iteraciones repetidas.

Sin embargo, todavía hay una cosa que mencionar sobre este ejemplo de código.


Veamos nuevamente la implementación reescrita:

 IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } } async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); // First producer call. return retrievalFunc().Select(t => t.Result); // Second producer call. }


En este caso, el productor crea nuevas instancias de tarea cada vez y lo llamamos dos veces. Esto nos lleva a un hecho bastante peculiar y no tan obvio: cuando llamamos Task.WhenAll y .Select(t => t.Result) las instancias de tarea en las que operan estos dos fragmentos de código son diferentes. Las tareas que se habían estado esperando (y que, por lo tanto, se completaron) no son las mismas tareas de las que el método devuelve los resultados.


Entonces, aquí el productor crea dos conjuntos diferentes de tareas. El primer conjunto de tareas se espera de forma asincrónica (la llamada Task.WhenAll ), pero no se espera el segundo conjunto de tareas. En cambio, el código llama directamente al captador de la propiedad Result , que es efectivamente el infame antipatrón sync-over-async. No entraría en detalles sobre este antipatrón, ya que es un tema extenso. Este artículo de Stephen Toub arroja bastante luz sobre el tema: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/


Sin embargo, para completar, aquí hay algunos problemas potenciales que este código puede causar:

  • bloqueos, cuando se utilizan en el escritorio (WinForms, WPF, MAUI) o en aplicaciones ASP.NET .Net Fx;
  • Falta de recursos en el grupo de subprocesos cuando hay cargas elevadas.


Si hacemos abstracción del código de ejemplo actual que estaba produciendo estas tareas simples, nos enfrentamos al hecho de que las iteraciones repetidas pueden causar fácilmente múltiples ejecuciones para cualquier operación, y puede que no sea idempotente (es decir, las llamadas posteriores con las mismas entradas están destinadas a producir resultados diferentes o incluso simplemente fallar). Por ejemplo, el saldo de la cuenta cambia.


Incluso si esas operaciones fueran idempotentes, podrían tener altos costos computacionales, y por lo tanto su ejecución repetida simplemente consumiría nuestros recursos en vano. Y si hablamos de código que se ejecuta en la nube, estos recursos podrían tener un costo que tendríamos que pagar.


Nuevamente, debido a que las iteraciones repetidas sobre instancias de IEnumerable son bastante fáciles de pasar por alto, puede ser muy difícil descubrir por qué una aplicación falla, gasta muchos recursos (incluido dinero) o hace cosas que no debería hacer.

Dándole un poco de sabor a las cosas.

Tomemos el código de prueba original y modifiquémoslo ligeramente:

 var externalService = new Service(); var cts = new CancellationTokenSource(); // New line. var results = (await RetrieveAndProcessDataAsync([1, 2, 3], cts.Token)); // Using cts.Token instead of a default token, and not materializing the IEnumerable. Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } cts.Cancel(); // New line. Console.WriteLine($"Getting the count again: {results.Count()}");


Dejaré que el lector intente ejecutar este código. Será una buena demostración de los posibles efectos secundarios que pueden surgir inesperadamente al repetir las iteraciones.

¿Cómo arreglar este código?

Echemos un vistazo:

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); // Adding .ToArray() call. await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }


Al agregar una única llamada .ToArray() al IEnumerable<Task<Data>> inicial, "materializaríamos" la instancia de IEnumerable en una colección en memoria, y cualquier iteración posterior sobre la colección en memoria haría exactamente lo que supondríamos: simplemente leería los datos de la memoria sin efectos secundarios inesperados causados por ejecuciones repetidas de código.


Básicamente, cuando los desarrolladores escriben este tipo de código (como en el ejemplo de código inicial), normalmente dan por sentado que estos datos están "grabados en piedra" y que nada inesperado ocurrirá cuando se acceda a ellos. Sin embargo, como acabamos de ver, esto está bastante lejos de la verdad.


Podríamos mejorar aún más el método, pero lo dejaremos para el siguiente capítulo.

Sobre la producción de un IEnumerable.

Acabamos de analizar los problemas que pueden surgir del uso de IEnumerable cuando se basa en conceptos erróneos, cuando no tiene en cuenta que ninguna de estas suposiciones debe realizarse al consumir IEnumerable:


  • el coste necesario para producir artículos, es decir, si un artículo se recuperó de algún tipo de almacenamiento (se reutilizó) o se creó;
  • si el mismo artículo puede volver a producirse en iteraciones posteriores;
  • cualquier posible efecto secundario que pudiera afectar (o no) a iteraciones posteriores.


Ahora, echemos un vistazo a la promesa que los productores de IEnumerable deberían (idealmente) cumplir para sus consumidores:

  • los artículos se producen "a pedido"; no se supone que se haya realizado ningún esfuerzo "por adelantado";
  • los consumidores tienen la libertad de detener la iteración en cualquier momento, y esto debería ahorrar los recursos que serían necesarios si el consumo continuara;
  • Si la iteración (consumo) no ha comenzado, no se deben utilizar recursos.


Nuevamente, revisemos nuestro ejemplo de código anterior desde este punto de vista.

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }


Básicamente, este código no cumple con estas promesas, ya que todo el trabajo pesado se realiza en las primeras dos líneas, antes de comenzar a producir el IEnumerable. Por lo tanto, si algún consumidor decidiera detener el consumo antes, o incluso no lo iniciara, el método QueryForDataAsync se seguiría llamando para todas las entradas.


Teniendo en cuenta el comportamiento de las dos primeras líneas, sería mucho mejor reescribir el método para producir una colección en memoria, como:

 async Task<IList<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result).ToArray(); }


Esta implementación no ofrece ninguna garantía "a pedido"; por el contrario, está muy claro que todo el trabajo necesario para procesar la entrada dada se completaría y se devolverían los resultados coincidentes.


Sin embargo, si necesitamos el comportamiento de "productor de datos a pedido", el método debería reescribirse por completo para proporcionarlo. Por ejemplo:

 async IAsyncEnumerable<Data> RetrieveAndProcessDataAsAsyncEnumerable(IList<int> ids, [EnumeratorCancellation] CancellationToken ct) { foreach (int id in ids) { var result = await externalService.QueryForData(id, ct); yield return result; } }


Si bien los desarrolladores no suelen pensar en estos detalles específicos del contrato de IEnumerable, otros códigos que lo consumen suelen hacer suposiciones que coinciden con estos detalles. Por lo tanto, cuando el código que produce IEnumerable coincide con esos detalles, toda la aplicación funcionará mejor.

Conclusión.

Espero que este artículo haya ayudado al lector a ver la diferencia entre un contrato de colección y los detalles del contrato IEnumerable. Las colecciones generalmente proporcionan algún almacenamiento para sus elementos (normalmente, en la memoria) y formas de revisar los elementos almacenados; las colecciones que no son de solo lectura también extienden este contrato al permitir modificar/agregar/eliminar los elementos almacenados. Si bien las colecciones son muy consistentes en cuanto a los elementos almacenados, IEnumerable declara esencialmente una volatilidad muy alta en este sentido, ya que los elementos se producen cuando se itera sobre una instancia de IEnumerable.


Entonces, ¿cuáles serían las mejores prácticas para IEnumerable? Démosles una lista de puntos:

  • Evite siempre las iteraciones repetidas, a menos que esto sea lo que realmente pretende y comprende las consecuencias. Es seguro encadenar varios métodos de extensión de Linq a una instancia de IEnumerable (como .Where y .Select ), pero cualquier otra llamada que provoque una iteración real es lo que se debe evitar. Si la lógica de procesamiento requiere múltiples pasadas sobre un IEnumerable, materialícela en una colección en memoria o revise si la lógica se puede cambiar a una sola pasada por elemento.
  • Cuando producir un IEnumerable implica código asincrónico, considere cambiarlo a IAsyncEnumerable o reemplazar IEnumerable con una representación "materializada"; por ejemplo, cuando prefiera aprovechar la ejecución paralela y devolver los resultados después de que se hayan completado todas las tareas.
  • El código que produce IEnumerable debe construirse de manera tal que permita evitar gastar recursos si la iteración se detuviera antes o no comenzara en absoluto.
  • No utilice IEnumerable para tipos de datos a menos que necesite sus especificaciones. Si su código necesita cierto grado de "generalización", prefiera otras interfaces de tipo de colección que no impliquen el comportamiento de "productor de datos a pedido", como IList o IReadOnlyCollection.