Initialisation des systèmes...

Baptiste.Dev
Retour aux notes
LangagesAvancéSérie : C# / .NET

C# : IEnumerable, yield et exécution différée

Le contrat derrière foreach, le state machine de yield return, l'exécution paresseuse et le piège de la ré-exécution.

Par Baptiste Vidal
4 min de lecture
Mis à jour hier
c#csharpienumerableyieldlinqlazyiterators

C# : IEnumerable, yield et exécution différée

IEnumerable<T> est le contrat le plus fondamental des collections. Il ne promet qu'une chose : on peut me parcourir. Mais derrière cette simplicité se cachent le foreach, yield, l'exécution paresseuse et toute la mécanique de LINQ.


# Le contrat : un curseur, rien de plus

IEnumerable<T> sait juste fournir un énumérateur, un curseur qui avance élément par élément :

public interface IEnumerable<T>
{
    IEnumerator<T> GetEnumerator();
}
 
public interface IEnumerator<T>
{
    bool MoveNext();   // avance ; false quand c'est fini
    T Current { get; } // l'élément courant
}

Il n'y a pas de Count, pas d'index. Un IEnumerable<T> ne connaît pas forcément sa taille : il sait seulement avancer d'un cran. C'est cette contrainte minimale qui le rend si puissant.


# foreach n'est que du sucre

Un foreach ne fait qu'appeler GetEnumerator puis boucler sur MoveNext :

// Ce que tu écris
foreach (int n in nombres)
{
    Console.WriteLine(n);
}
 
// Ce que le compilateur génère (en simplifié)
IEnumerator<int> e = nombres.GetEnumerator();
while (e.MoveNext())
{
    int n = e.Current;
    Console.WriteLine(n);
}

Toute classe qui expose un GetEnumerator peut donc être parcourue au foreach. C'est comme ça que tes propres types deviennent itérables.


# yield return : générer paresseusement

Écrire un énumérateur à la main est fastidieux. Le mot-clé yield return demande au compilateur de le générer pour toi :

public IEnumerable<int> Pairs(int max)
{
    for (int i = 0; i <= max; i += 2)
    {
        yield return i;   // rend un élément, puis reprend ICI au tour suivant
    }
}

Le compilateur transforme cette méthode en une machine à états : à chaque appel de MoveNext, l'exécution reprend exactement là où le dernier yield return l'avait laissée, variables locales conservées. Rien n'est stocké : les éléments sont produits un par un, à la demande.


# Exécution différée (lazy)

Conséquence directe : le corps d'une méthode yield (ou d'une requête LINQ) ne s'exécute pas au moment où tu l'appelles. Il attend que tu consommes le résultat.

IEnumerable<int> pairs = Pairs(10);
Console.WriteLine("Rien ne s'est encore exécuté");
 
foreach (int n in pairs)   // c'est ICI que Pairs commence vraiment à tourner
{
    Console.WriteLine(n);
}

Where, Select, Pairs... retournent une description de la séquence, pas ses éléments. Le calcul démarre au premier MoveNext (un foreach, un ToList, un First, un Count).

Cet avantage est double : on peut chaîner plusieurs opérations sans parcourir la collection plusieurs fois, et on ne calcule que ce qui est réellement consommé (un First() s'arrête au premier élément).


# Le piège de la ré-exécution

Puisqu'un IEnumerable<T> est une recette et non un résultat, le parcourir deux fois le ré-exécute deux fois.

IEnumerable<int> pairs = nombres.Where(n => { Console.WriteLine($"test {n}"); return n % 2 == 0; });
 
int combien = pairs.Count();   // exécute le filtre une fois
int premier = pairs.First();   // le RE-exécute une deuxième fois

Si la source change entre deux parcours, le résultat change aussi. Et si le calcul est coûteux, tu le paies à chaque itération. La parade : matérialiser une bonne fois avec ToList() ou ToArray().

List<int> pairs = nombres.Where(n => n % 2 == 0).ToList();
// désormais c'est une vraie liste, figée et parcourable autant de fois qu'on veut

# Des séquences infinies

Comme les éléments sont produits à la demande, un IEnumerable<T> peut être infini sans faire exploser la mémoire. On le borne à la consommation.

public IEnumerable<int> Entiers()
{
    int i = 0;
    while (true) yield return i++;   // infini... mais paresseux
}
 
var dix = Entiers().Take(10).ToList();   // { 0, 1, ..., 9 }

Take(10) arrête l'énumération après dix MoveNext. La boucle while (true) ne tourne jamais indéfiniment tant que personne ne consomme au-delà.


# Rendre sa propre classe itérable

Il suffit d'implémenter IEnumerable<T> et d'y déléguer un yield :

public class Semaine : IEnumerable<string>
{
    private readonly string[] _jours =
        { "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche" };
 
    public IEnumerator<string> GetEnumerator()
    {
        foreach (string jour in _jours) yield return jour;
    }
 
    // Version non générique historique, requise par le contrat
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
 
foreach (string jour in new Semaine()) { /* ... */ }   // ça marche

Ta classe gagne d'un coup le foreach et tout LINQ (.Where, .Select, .Count...), puisque tout cela ne demande qu'un IEnumerable<T>.


# La suite