Partager via


Itérateurs

Presque tous les programmes que vous écrivez auront besoin d’itérer sur une collection. Vous allez écrire du code qui examine chaque élément d’une collection.

Vous allez également créer des méthodes d’itérateur, qui sont des méthodes qui produisent un itérateur pour les éléments de cette classe. Un itérateur est un objet qui traverse un conteneur, en particulier des listes. Les itérateurs peuvent être utilisés pour :

  • Exécution d’une action sur chaque élément d’une collection.
  • Énumération d’une collection personnalisée.
  • Extension de LINQ ou d’autres bibliothèques.
  • Création d’un pipeline de données où les données circulent efficacement via des méthodes itérateurs.

Le langage C# fournit des fonctionnalités pour générer et consommer des séquences. Ces séquences peuvent être produites et consommées de manière synchrone ou asynchrone. Cet article fournit une vue d’ensemble de ces fonctionnalités.

Itération avec foreach

Énumération d’une collection est simple : le foreach mot clé énumère une collection, en exécutant l’instruction incorporée une fois pour chaque élément de la collection :

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

C’est tout. Pour itérer sur tout le contenu d’une collection, l’instruction foreach est tout ce dont vous avez besoin. L’affirmation foreach n’est pas magique, cependant. Il s’appuie sur deux interfaces génériques définies dans la bibliothèque .NET Core pour générer le code nécessaire pour itérer une collection : IEnumerable<T> et IEnumerator<T>. Ce mécanisme est expliqué plus en détail ci-dessous.

Ces deux interfaces ont également des équivalents non génériques : IEnumerable et IEnumerator. Les versions génériques sont préférées pour le code moderne.

Lorsqu’une séquence est générée de façon asynchrone, vous pouvez utiliser l’instruction await foreach pour consommer de manière asynchrone la séquence :

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

Quand une séquence est un System.Collections.Generic.IEnumerable<T>, vous utilisez foreach. Quand une séquence est un System.Collections.Generic.IAsyncEnumerable<T>, vous utilisez await foreach. Dans ce dernier cas, la séquence est générée de manière asynchrone.

Sources d’énumération avec des méthodes d’itérateur

Une autre fonctionnalité intéressante du langage C# vous permet de créer des méthodes qui créent une source pour une énumération. Ces méthodes sont appelées méthodes d’itérateur. Une méthode d’itérateur définit comment générer les objets dans une séquence quand elle est demandée. Vous utilisez les yield return mots clés contextuels pour définir une méthode d’itérateur.

Vous pouvez écrire cette méthode pour produire la séquence d’entiers compris entre 0 et 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;
}

Le code ci-dessus montre des instructions distinctes yield return pour mettre en évidence le fait que vous pouvez utiliser plusieurs instructions discrètes yield return dans une méthode d’itérateur. Vous pouvez (et souvent) utiliser d’autres constructions de langage pour simplifier le code d’une méthode d’itérateur. La définition de méthode ci-dessous produit exactement la même séquence de nombres :

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

Vous n’avez pas à décider l’un ou l’autre. Vous pouvez avoir autant d’instructions yield return que nécessaire pour répondre aux besoins de votre méthode :

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

    yield return 50;

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

Tous ces exemples précédents auraient un équivalent asynchrone. Dans chaque cas, vous remplacez le type de retour par IEnumerable<T> un IAsyncEnumerable<T>. Par exemple, l’exemple précédent aurait la version asynchrone suivante :

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

Il s’agit de la syntaxe des itérateurs synchrones et asynchrones. Prenons un exemple réel. Imaginez que vous êtes sur un projet IoT et que les capteurs d’appareil génèrent un flux de données très volumineux. Pour obtenir une idée des données, vous pouvez écrire une méthode qui échantillonne chaque élément de données Nth. Cette petite méthode d’itérateur effectue l’astuce :

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 lecture à partir de l’appareil IoT produit une séquence asynchrone, vous devez modifier la méthode comme le montre la méthode suivante :

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

Il existe une restriction importante sur les méthodes d’itérateur : vous ne pouvez pas avoir à la fois une return instruction et une yield return instruction dans la même méthode. Le code suivant ne sera pas compilé :

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

Cette restriction n’est normalement pas un problème. Vous avez le choix entre l’utilisation yield return de toute la méthode ou la séparation de la méthode d’origine en plusieurs méthodes, certaines utilisant, et certaines à l’aide returnyield return.

Vous pouvez modifier légèrement la dernière méthode pour l’utiliser yield return partout :

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

Parfois, la bonne réponse consiste à fractionner une méthode d’itérateur en deux méthodes différentes. Une qui utilise return, et une seconde qui utilise yield return. Considérez une situation dans laquelle vous souhaiterez peut-être retourner une collection vide, ou les cinq premiers nombres impairs, en fonction d’un argument booléen. Vous pouvez écrire cela en tant que ces deux méthodes :

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

Examinez les méthodes ci-dessus. La première utilise l’instruction standard return pour retourner une collection vide ou l’itérateur créé par la deuxième méthode. La deuxième méthode utilise l’instruction yield return pour créer la séquence demandée.

Immersion plus approfondie dans foreach

L’instruction foreach s’étend en un idiome standard qui utilise les interfaces et IEnumerator<T> les IEnumerable<T> utilise pour itérer sur tous les éléments d’une collection. Il réduit également les erreurs que les développeurs font en ne gérant pas correctement les ressources.

Le compilateur traduit la foreach boucle affichée dans le premier exemple en quelque chose de similaire à cette construction :

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

Le code exact généré par le compilateur est plus compliqué et gère les situations où l’objet retourné par GetEnumerator() implémente l’interface IDisposable . L’extension complète génère du code plus comme suit :

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

Le compilateur traduit le premier exemple asynchrone en quelque chose de similaire à cette construction :

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

La façon dont l’énumérateur est supprimé dépend des caractéristiques du type de enumerator. Dans le cas synchrone général, la finally clause se développe sur :

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

Le cas asynchrone général s’étend sur :

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

Toutefois, si le type d’un enumerator type est un type scellé et qu’il n’y a pas de conversion implicite du type de enumerator vers IDisposable ou IAsyncDisposable, la finally clause s’étend vers un bloc vide :

finally
{
}

S’il existe une conversion implicite du type vers enumeratorIDisposable, et enumerator s’il s’agit d’un type valeur non nullable, la finally clause se développe sur :

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

Heureusement, vous n’avez pas besoin de mémoriser tous ces détails. L’instruction foreach gère toutes ces nuances pour vous. Le compilateur génère le code approprié pour l’une de ces constructions.