Compartir a través de


Iteradores

Casi todos los programas que escriba tendrán que iterar en una colección. Escribirá código que examine todos los elementos de una colección.

También creará métodos de iterador, que son métodos que generan un iterador para los elementos de esa clase. Un iterador es un objeto que atraviesa un contenedor, especialmente las listas. Los iteradores se pueden usar para:

  • Realizar una acción en cada elemento de una colección.
  • Enumeración de una colección personalizada.
  • Extensión de LINQ u otras bibliotecas.
  • Creación de una canalización de datos en la que los datos fluyen de forma eficaz a través de métodos de iterador.

El lenguaje C# proporciona características para generar y consumir secuencias. Estas secuencias se pueden producir y consumir de forma sincrónica o asincrónica. En este artículo se proporciona información general sobre esas características.

Iteración con foreach

La enumeración de una colección es sencilla: la foreach palabra clave enumera una colección y ejecuta la instrucción insertada una vez para cada elemento de la colección:

foreach (var item in collection)
{
    Console.WriteLine(item?.ToString());
}

Es todo. Para iterar todo el contenido de una colección, la foreach instrucción es todo lo que necesita. Sin embargo, la foreach instrucción no es mágica. Se basa en dos interfaces genéricas definidas en la biblioteca de .NET Core para generar el código necesario para iterar una colección: IEnumerable<T> y IEnumerator<T>. Este mecanismo se explica con más detalle a continuación.

Ambas interfaces también tienen homólogos no genéricos: IEnumerable y IEnumerator. Se prefieren las versiones genéricas para el código moderno.

Cuando se genera una secuencia de forma asincrónica, puede usar la await foreach instrucción para consumir de forma asincrónica la secuencia:

await foreach (var item in asyncSequence)
{
Console.WriteLine(item?.ToString());
}

Cuando una secuencia es , System.Collections.Generic.IEnumerable<T>se usa foreach. Cuando una secuencia es , System.Collections.Generic.IAsyncEnumerable<T>se usa await foreach. En este último caso, la secuencia se genera de forma asincrónica.

Orígenes de enumeración con métodos de iterador

Otra gran característica del lenguaje C# permite crear métodos que crean un origen para una enumeración. Estos métodos se conocen como métodos de iterador. Un método de iterador define cómo generar los objetos en una secuencia cuando se solicita. Use las yield return palabras clave contextuales para definir un método de iterador.

Puede escribir este método para generar la secuencia de enteros de 0 a 9:

public IEnumerable<int> GetSingleDigitNumbers()
{
    yield return 0;
    yield return 1;
    yield return 2;
    yield return 3;
    yield return 4;
    yield return 5;
    yield return 6;
    yield return 7;
    yield return 8;
    yield return 9;
}

El código anterior muestra instrucciones distintas yield return para resaltar el hecho de que puede usar varias instrucciones discretas yield return en un método de iterador. Puede (y a menudo hacer) usar otras construcciones de lenguaje para simplificar el código de un método de iterador. La siguiente definición de método genera la misma secuencia exacta de números:

public IEnumerable<int> GetSingleDigitNumbersLoop()
{
    int index = 0;
    while (index < 10)
        yield return index++;
}

No tienes que decidir uno ni otro. Puede tener tantas yield return instrucciones como sea necesario para satisfacer las necesidades del método:

public IEnumerable<int> GetSetsOfNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    index = 100;
    while (index < 110)
        yield return index++;
}

Todos estos ejemplos anteriores tendrían un homólogo asincrónico. En cada caso, reemplazaría el tipo de valor devuelto de IEnumerable<T> por .IAsyncEnumerable<T> Por ejemplo, el ejemplo anterior tendría la siguiente versión asincrónica:

public async IAsyncEnumerable<int> GetSetsOfNumbersAsync()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    await Task.Delay(500);

    yield return 50;

    await Task.Delay(500);

    index = 100;
    while (index < 110)
        yield return index++;
}

Esa es la sintaxis de los iteradores sincrónicos y asincrónicos. Consideremos un ejemplo real. Imagine que está en un proyecto de IoT y los sensores de dispositivo generan un flujo de datos muy grande. Para obtener una idea de los datos, puede escribir un método que muestree cada elemento de datos Nth. Este pequeño método de iterador hace el truco:

public static IEnumerable<T> Sample<T>(this IEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

Si la lectura del dispositivo IoT genera una secuencia asincrónica, modificaría el método como se muestra en el método siguiente:

public static async IAsyncEnumerable<T> Sample<T>(this IAsyncEnumerable<T> sourceSequence, int interval)
{
    int index = 0;
    await foreach (T item in sourceSequence)
    {
        if (index++ % interval == 0)
            yield return item;
    }
}

Hay una restricción importante en los métodos de iterador: no se puede tener una return instrucción ni una yield return instrucción en el mismo método. El código siguiente no se compilará:

public IEnumerable<int> GetSingleDigitNumbers()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    // generates a compile time error:
    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    return items;
}

Esta restricción normalmente no es un problema. Tiene una opción de usar yield return en todo el método o separar el método original en varios métodos, algunos usan returny otros usan yield return.

Puede modificar el último método ligeramente para usarlo yield return en todas partes:

public IEnumerable<int> GetFirstDecile()
{
    int index = 0;
    while (index < 10)
        yield return index++;

    yield return 50;

    var items = new int[] {100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
    foreach (var item in items)
        yield return item;
}

A veces, la respuesta correcta es dividir un método de iterador en dos métodos diferentes. Uno que usa returny un segundo que usa yield return. Considere una situación en la que podría querer devolver una colección vacía o los cinco primeros números impares, en función de un argumento booleano. Puede escribirlo como estos dos métodos:

public IEnumerable<int> GetSingleDigitOddNumbers(bool getCollection)
{
    if (getCollection == false)
        return new int[0];
    else
        return IteratorMethod();
}

private IEnumerable<int> IteratorMethod()
{
    int index = 0;
    while (index < 10)
    {
        if (index % 2 == 1)
            yield return index;
        index++;
    }
}

Examine los métodos anteriores. La primera usa la instrucción estándar return para devolver una colección vacía o el iterador creado por el segundo método. El segundo método usa la yield return instrucción para crear la secuencia solicitada.

Profundización en foreach

La foreach instrucción se expande en un lenguaje estándar que usa las IEnumerable<T> interfaces y IEnumerator<T> para iterar en todos los elementos de una colección. También minimiza los errores que los desarrolladores realizan al no administrar correctamente los recursos.

El compilador traduce el foreach bucle que se muestra en el primer ejemplo en algo similar a esta construcción:

IEnumerator<int> enumerator = collection.GetEnumerator();
while (enumerator.MoveNext())
{
    var item = enumerator.Current;
    Console.WriteLine(item.ToString());
}

El código exacto generado por el compilador es más complicado y controla situaciones en las que el objeto devuelto por GetEnumerator() implementa la IDisposable interfaz. La expansión completa genera código más similar al siguiente:

{
    var enumerator = collection.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of enumerator.
    }
}

El compilador traduce el primer ejemplo asincrónico en algo similar a esta construcción:

{
    var enumerator = collection.GetAsyncEnumerator();
    try
    {
        while (await enumerator.MoveNextAsync())
        {
            var item = enumerator.Current;
            Console.WriteLine(item.ToString());
        }
    }
    finally
    {
        // dispose of async enumerator.
    }
}

La manera en que se elimina el enumerador depende de las características del tipo de enumerator. En el caso sincrónico general, la finally cláusula se expande a:

finally
{
   (enumerator as IDisposable)?.Dispose();
}

El caso asincrónico general se expande a:

finally
{
    if (enumerator is IAsyncDisposable asyncDisposable)
        await asyncDisposable.DisposeAsync();
}

Sin embargo, si el tipo de enumerator es un tipo sellado y no hay ninguna conversión implícita del tipo de enumerator a IDisposable o IAsyncDisposable, la finally cláusula se expande a un bloque vacío:

finally
{
}

Si hay una conversión implícita del tipo de a enumeratory IDisposable es un tipo de enumerator valor que no acepta valores NULL, la finally cláusula se expande a:

finally
{
   ((IDisposable)enumerator).Dispose();
}

Afortunadamente, no es necesario recordar todos estos detalles. La foreach instrucción controla todos esos matices para usted. El compilador generará el código correcto para cualquiera de estas construcciones.