Iterators

Presque chaque programme que vous écrivez doit itérer au sein d’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, c’est-à-dire des méthodes qui produisent un itérateur pour les éléments de cette classe. Un itérateur est un objet qui parcourt un conteneur, notamment des listes. Les itérateurs peuvent être utilisés pour :

  • Effectuer une action sur chaque élément d’une collection.
  • Énumérer une collection personnalisée.
  • Étendre LINQ ou d’autres bibliothèques.
  • Créer un pipeline de données où les données circulent efficacement via des méthodes d’itérateur.

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

Itération avec foreach

L’énumération d’une collection est simple : le mot clé foreach é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 au sein du contenu d’une collection, l’instruction foreach suffit. L’instruction foreach n’est cependant pas magique. Cette méthode s’appuie sur deux interfaces génériques définies dans la bibliothèque .NET Core afin de générer le code nécessaire à l’itération d’une collection : IEnumerable<T> et IEnumerator<T>. Ce mécanisme est expliqué plus en détail ci-dessous.

Ces deux interfaces ont également des contreparties non génériques : IEnumerable et IEnumerator. Les versions génériques conviennent mieux pour un code moderne.

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

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

Lorsqu’une séquence correspond à System.Collections.Generic.IEnumerable<T>, vous utilisez foreach. Lorsqu’une séquence correspond à 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 construire des méthodes qui créent une source pour une énumération. Ces méthodes sont appelées des méthodes d’itérateur. Une méthode d’itérateur définit comment générer les objets dans une séquence sur demande. Vous utilisez les mots clés contextuels yield return pour définir une méthode d’itérateur.

Vous pouvez par exemple écrire cette méthode pour produire la séquence d’entiers de 0 à 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 yield return distinctes pour mettre en évidence le fait que vous pouvez utiliser plusieurs instructions yield return discrètes dans une méthode d’itérateur. Vous pouvez (et c’est souvent le cas) utiliser d’autres constructions du 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’êtes pas obligé de choisir l’une 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 les exemples précédents auraient un équivalent asynchrone. Dans chaque cas, vous devez remplacer le type de retour de IEnumerable<T> par IAsyncEnumerable<T>. Par exemple, la version asynchrone de l’exemple précédent serait la 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 concret. Imaginez que vous êtes sur un projet IoT et que les capteurs d’un appareil génèrent un flux de données très important. Pour obtenir un aperçu des données, vous pouvez écrire une méthode qui échantillonne les éléments de données à un certain intervalle. Cette petite méthode d’itérateur peut effectuer ce travail :

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 indiqué dans 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 concernant les méthodes d’itérateur : vous ne pouvez pas avoir à la fois une instruction return et une instruction yield return 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 utiliser yield return dans toute la méthode ou de diviser la méthode d’origine en plusieurs méthodes, certaines utilisant return et d’autres utilisant yield return.

Vous pouvez modifier légèrement la dernière méthode pour 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 est de fractionner une méthode d’itérateur en deux méthodes différentes. Une qui utilise return et l’autre qui utilise yield return. Imaginons que vous vouliez renvoyer une collection vide, ou les cinq premiers nombres impairs, en vous basant sur un argument booléen. Vous pouvez écrire ceci sous la forme de 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 return standard 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.

Aller plus loin avec foreach

L’instruction foreach se développe en un idiome standard qui utilise les interfaces IEnumerable<T> et IEnumerator<T> pour itérer à travers tous les éléments d’une collection. Cette méthode réduit également les erreurs que font les développeurs en ne gérant pas correctement les ressources.

Le compilateur traduit la boucle foreach présenté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 cas où l’objet renvoyé par GetEnumerator() implémente l’interface IDisposable. L’expansion complète génère un code qui ressemble davantage à celui-ci :

{
    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 manière dont l’énumérateur est éliminé dépend des caractéristiques du type d’enumerator. Dans le cas synchrone général, la clause finally se développe en :

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

Le cas asynchrone général se développe en :

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

Cependant, si le type d’enumerator est un type sealed et qu’il n’existe pas de conversion implicite du type d’enumerator en IDisposable ou IAsyncDisposable, la clause finally se développe en un bloc vide :

finally
{
}

S’il existe une conversion implicite du type d’enumerator en IDisposable et si enumerator est un type valeur qui n’autorise pas les valeurs Null, la clause finally se développe en :

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

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