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.
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
}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);
}// Ce que tu écrisforeach (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
}
}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);
}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 foisIEnumerable<int> pairs = nombres.Where(n => { Console.WriteLine($"test {n}"); return n % 2 == 0; }); int combien = pairs.Count(); // exécute le filtre une foisint premier = pairs.First(); // le RE-exécute une deuxième foisSi 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 veutList<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 }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 marchepublic 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 marcheTa classe gagne d'un coup le foreach et tout LINQ (.Where, .Select, .Count...), puisque tout cela ne demande qu'un IEnumerable<T>.
# La suite
- Les interfaces de collection - la note parente, toute la hiérarchie
- LINQ - construit entièrement sur
IEnumerable<T>et l'exécution différée - Collections & génériques - le hub de la série