Condividi tramite


Iteratori

Quasi ogni programma scritto avrà bisogno di eseguire un'iterazione su una raccolta. Si scriverà codice che esamina ogni elemento in una raccolta.

Si creeranno anche metodi iteratori, ovvero metodi che producono un iteratore per gli elementi di tale classe. Un iteratore è un oggetto che attraversa un contenitore, in particolare elenchi. Gli iteratori possono essere usati per:

  • Esecuzione di un'azione su ogni elemento di una raccolta.
  • Enumerazione di una raccolta personalizzata.
  • Estensione di LINQ o di altre librerie.
  • Creazione di una pipeline di dati in cui i dati vengono trasmessi in modo efficiente tramite metodi iteratori.

Il linguaggio C# offre funzionalità per la generazione e l'utilizzo di sequenze. Queste sequenze possono essere prodotte e utilizzate in modo sincrono o asincrono. Questo articolo offre una panoramica di queste funzionalità.

Iterare utilizzando il comando "foreach"

L'enumerazione di una raccolta è semplice: la foreach parola chiave enumera una raccolta, eseguendo l'istruzione incorporata una volta per ogni elemento della raccolta:

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

Questo è tutto. Per scorrere tutto il contenuto di una raccolta, l'istruzione foreach è sufficiente. L'istruzione foreach non è però magica. Si basa su due interfacce generiche definite nella libreria .NET Core per generare il codice necessario per scorrere una raccolta: IEnumerable<T> e IEnumerator<T>. Questo meccanismo è illustrato in modo più dettagliato di seguito.

Entrambe queste interfacce hanno anche controparti non generiche: IEnumerable e IEnumerator. Le versioni generiche sono preferite per il codice moderno.

Quando una sequenza viene generata in modo asincrono, è possibile usare l'istruzione await foreach per utilizzare in modo asincrono la sequenza:

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

Quando una sequenza è un System.Collections.Generic.IEnumerable<T>, si usa foreach. Quando una sequenza è un System.Collections.Generic.IAsyncEnumerable<T>, si usa await foreach. In quest'ultimo caso, la sequenza viene generata in modo asincrono.

Origini di enumerazione con metodi iteratore

Un'altra grande funzionalità del linguaggio C# consente di creare metodi che creano un'origine per un'enumerazione. Questi metodi vengono definiti metodi iteratori. Un metodo iteratore definisce come generare gli oggetti in una sequenza quando richiesto. Usare le yield return parole chiave contestuali per definire un metodo iteratore.

È possibile scrivere questo metodo per produrre la sequenza di interi da 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;
}

Il codice precedente mostra istruzioni distinte yield return per evidenziare il fatto che è possibile usare più istruzioni discrete yield return in un metodo iteratore. È possibile (e spesso) usare altri costrutti di linguaggio per semplificare il codice di un metodo iteratore. La definizione del metodo seguente produce la stessa sequenza di numeri:

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

Non devi decidere uno o l'altro. È possibile disporre di quante yield return istruzioni siano necessarie per soddisfare le esigenze del metodo:

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

    yield return 50;

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

Tutti questi esempi precedenti avrebbero una controparte asincrona. In ogni caso, sostituire il tipo di ritorno di IEnumerable<T> con IAsyncEnumerable<T>. Ad esempio, l'esempio precedente avrà la versione asincrona seguente:

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++;
}

Questa è la sintassi sia per gli iteratori sincroni che per gli iteratori asincroni. Si consideri un esempio reale. Si supponga di essere in un progetto IoT e che i sensori di dispositivo generino un flusso di dati molto grande. Per ottenere informazioni sui dati, è possibile scrivere un metodo che esegue il campionamento di ogni elemento dati Nth. Questo piccolo metodo iteratore esegue il trucco:

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;
    }
}

Se la lettura dal dispositivo IoT produce una sequenza asincrona, è necessario modificare il metodo come illustrato nel metodo seguente:

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;
    }
}

Esiste una restrizione importante per i metodi iteratori: non è possibile avere sia un'istruzione return che un'istruzione yield return nello stesso metodo. Il codice seguente non verrà compilato:

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;
}

Questa restrizione in genere non è un problema. È possibile scegliere di usare yield return in tutto il metodo o separare il metodo originale in più metodi, alcuni usando returne alcuni usando yield return.

È possibile modificare leggermente l'ultimo metodo per usare yield return ovunque:

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;
}

In alcuni casi, la risposta corretta consiste nel suddividere un metodo iteratore in due metodi diversi. Uno che usa returne un secondo che usa yield return. Si consideri una situazione in cui si potrebbe voler restituire una raccolta vuota o i primi cinque numeri dispari, in base a un argomento booleano. È possibile rappresentare questo utilizzando questi due metodi:

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++;
    }
}

Esamina i metodi sopra. Il primo usa l'istruzione standard return per restituire una raccolta vuota o l'iteratore creato dal secondo metodo. Il secondo metodo usa l'istruzione yield return per creare la sequenza richiesta.

Approfondimento approfondito foreach

L'istruzione foreach si traduce in un'espressione standard che usa le interfacce IEnumerable<T> e IEnumerator<T> per iterare su tutti gli elementi di una raccolta. Riduce inoltre al minimo gli errori che gli sviluppatori effettuano non gestendo correttamente le risorse.

Il compilatore converte il foreach ciclo illustrato nel primo esempio in qualcosa di simile al costrutto seguente:

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

Il codice esatto generato dal compilatore è più complesso e gestisce le situazioni in cui l'oggetto restituito da GetEnumerator() implementa l'interfaccia IDisposable . L'espansione completa genera codice più simile al seguente:

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

Il compilatore converte il primo esempio asincrono in qualcosa di simile al costrutto seguente:

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

Il modo in cui l'enumeratore viene eliminato dipende dalle caratteristiche del tipo di enumerator. Nel caso sincrono generale, la finally clausola si espande in:

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

Il caso asincrono generale si espande in:

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

Tuttavia, se il tipo di enumerator è un tipo sealed e non esiste alcuna conversione implicita dal tipo di enumerator a IDisposable o IAsyncDisposable, la finally clausola si espande in un blocco vuoto:

finally
{
}

Se è presente una conversione implicita dal tipo di enumerator a IDisposable e enumerator è un tipo di valore non annullabile, finally clausola si espande in:

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

Fortunatamente, non è necessario ricordare tutti questi dettagli. L'istruzione foreach gestisce tutte queste sfumature. Il compilatore genererà il codice corretto per uno di questi costrutti.