Cuando tenemos una colección de datos para procesar, es relativamente habitual tener que hacerlo por lotes, es decir, dividir la colección en partes más pequeñas e irlas procesando por separado.
Para ello, normalmente nos vemos obligados a trocear los datos en lotes o chunks de un tamaño determinado, y luego procesar cada uno de ellos.
Por ejemplo, imaginad que tenemos un método que recibe una lista de objetos a procesar y, por temas de rendimiento o lo que sea, sólo puede hacerlo de tres en tres. Si la lista de elementos a procesar es más larga de la cuenta, nos veremos previamente obligados a trocearla en lotes de tres para ir procesando cada uno de ellos por separado.
Tradicionalmente, es algo que hemos hecho de forma manual con un sencillo bucle for
que recorre la lista completa, y va acumulando los elementos en un nuevo lote hasta que alcanza el tamaño deseado, momento en el que lo procesa y lo vacía para empezar de nuevo.
Pero otra posibilidad bastante práctica, y probablemente más legible, sería pasar la lista de elementos previamente por otro proceso que retorne una lista de chunks, o pequeñas porciones de la lista original, para que luego simplemente tengamos que ir procesándolos secuencialmente. Es decir, algo así:
private char[] array = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
private int chunkSize = 3;
var chunks = GetChunks(array, chunkSize); // [['a', 'b', 'c'], ['d', 'e', 'f'], ['g', 'h', 'i'], ['j']
foreach (var chunk in chunks)
{
ProcessChunk(chunk); // 'chunk' sería un array de 3 elementos
}
Y la pregunta es, ¿cómo podríamos implementar ese método GetChunks()
en C#? Vamos a ver varias opciones.
Primera: bucle simple
La primera opción que sin duda se nos vendrá a la cabeza será el típico bucle for
que recorre la lista original y va acumulando elementos en un nuevo lote hasta que alcanza el tamaño deseado. En este momento, añade el chunk a la lista de lotes y lo vacía para empezar de nuevo.
Es una solución sencilla y eficaz, que podría implementarse más o menos como sigue:
public IEnumerable<char[]> GetChunks(char[] chars, int chunkSize)
{
var chunks = new List<char[]>();
var chunk = new List<char>();
for (int i = 0; i < chars.Length; i ++)
{
chunk.Add(chars[i]);
if (chunk.Count == chunkSize)
{
chunks.Add(chunk.ToArray());
chunk.Clear();
}
}
if (chunk.Any())
{
chunks.Add(chunk.ToArray());
}
return chunks;
}
Segunda: bucle simplificado
Partiendo de la opción anterior, y aprovechando que la secuencia de entrada es un array, podemos mejorar un poco el algoritmo para ir recorriendo la colección a saltos, en lugar de elemento a elemento, y usar los operadores de LINQ como Skip()
y Take()
para ir cogiendo los elementos que nos interesan:
IEnumerable<char[]> GetChunks(char[] chars, int chunkSize)
{
var chunks = new List<char[]>();
for (int i = 0; i < chars.Length; i += chunkSize)
{
var chunk = chars.Skip(i).Take(chunkSize).ToArray();
chunks.Add(chunk);
}
return chunks;
}
Tercera: todo a LINQ
Aunque la versión anterior ha simplificado bastante nuestro código, aún podemos hacerlo más legible y compacto haciendo uso de la potencia de LINQ. En este caso, podemos usar el método Select()
para ir recorriendo la colección original y devolviendo un chunk de elementos en cada iteración. Observad que usamos GroupBy()
para agrupar los elementos en lotes de tamaño chunkSize
:
IEnumerable<char[]> GetChunks(char[] chars, int chunkSize)
{
return chars.Select((c, i) => new {c, i})
.GroupBy(x => x.i / chunkSize)
.Select(g => g.Select(x => x.c).ToArray());
}
Cuarta: LINQ simplificado con la cláusula Chunk()
Pues si ya creíamos haberlo visto todo con la opción anterior, esperaos a ver esta 😉 Resulta que con .NET 6 se introdujo en LINQ el método Chunk()
, que básicamente hace lo que queremos: dividir los elementos de una sencuencia en fragmentos de un tamaño máximo.
Usándolo, nuestro método quedaría tan simple como esto:
IEnumerable<char[]> GetChunks(char[] chars, int chunkSize)
{
return chars.Chunk(chunkSize);
}
¿Y el rendimiento?
Pues la siguiente tabla, obtenida con nuestro amigo BenchmarkDotNet, nos muestra los resultados de las pruebas de rendimiento de cada uno de los métodos anteriores.
Como podéis ver, gana por paliza la última opción que hemos visto, la que utiliza Chunk()
, pues es la que rinde mejor y ocupa menos memoria, con diferencia.
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3155/23H2/2023Update/SunValley3)
Intel Core i9-9900K CPU 3.60GHz (Coffee Lake), 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.102
[Host] : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|------------------------- |-----------:|----------:|----------:|-------:|----------:|
| Fourth-LINQ-Chunk | 7.719 ns | 0.1662 ns | 0.2101 ns | 0.0086 | 72 B |
| Third-LINQ | 45.799 ns | 0.9308 ns | 2.2480 ns | 0.0315 | 264 B |
| Second-Simplified-Loop | 166.823 ns | 3.3372 ns | 5.7564 ns | 0.0715 | 600 B |
| First-Simple-Loop | 81.524 ns | 1.6638 ns | 1.5563 ns | 0.0334 | 280 B |
¡Espero que os haya resultado interesante!
Publicado en Variable not found.
Aún no hay comentarios, ¡sé el primero!
Enviar un nuevo comentario