1. Introduction
Imaginez que vous devez traiter un fichier de logs gigantesque contenant des millions de lignes, ou générer une séquence de nombres incroyablement longue. Comment feriez-vous cela sans générateurs ?
L'approche traditionnelle ressemblerait à ceci :
// Problème : génère toute la collection immédiatement en mémoire
List<int> GenerateAllNumbersSync(int count)
{
List<int> numbers = new List<int>();
for (int i = 0; i < count; i++)
{
numbers.Add(i);
}
return numbers; // On retourne quand tout est prêt
}
// Utilisation :
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 million de nombres en mémoire d'un coup !
foreach (var num in myNumbers) { /* Traitement */ }
Quels sont les problèmes ici ?
- Consommation mémoire : Si count est très grand, toute la collection est créée en mémoire, ce qui peut conduire à un OutOfMemoryException.
- Latence : L'utilisateur ou la partie suivante du programme doit attendre que toutes les données soient complètement générées et chargées en mémoire.
- Séquences infinies : Cette approche ne fonctionne tout simplement pas si la séquence est potentiellement infinie.
Les générateurs viennent à la rescousse ! Ils implémentent le concept de calcul paresseux (Lazy Evaluation) et le traitement en streaming. Au lieu de générer toutes les données d'un coup, le générateur produit les éléments un par un, seulement quand ils sont réellement nécessaires.
2. Bases des générateurs
En C#, les générateurs sont créés avec le mot-clé spécial yield.
Qu'est-ce qu'un générateur ?
C'est une méthode, un bloc get d'une propriété ou un opérateur qui contient une ou plusieurs expressions yield return.
yield return
C'est le cœur des générateurs. Quand le compilateur rencontre yield return :
- L'élément indiqué après yield return est transmis au code appelant.
- L'exécution de la méthode-générateur est suspendue, et son état actuel (où elle en est dans la boucle, les valeurs des variables locales) est sauvegardé.
- Lors de la prochaine demande d'élément (par exemple, à l'itération suivante du foreach), l'exécution de la méthode reprend à l'endroit où elle avait été suspendue.
Type de retour : La méthode-générateur doit retourner IEnumerable<T> ou IEnumerator<T>. Le compilateur générera toute la « magie » nécessaire.
// Exemple 2.1 : Générateur simple de nombres
IEnumerable<int> GenerateNumbers(int count)
{
Console.WriteLine("Début de la génération...");
for (int i = 0; i < count; i++)
{
Console.WriteLine($"Je génère : {i}");
yield return i; // Suspension et retour de l'élément
}
Console.WriteLine("Génération terminée.");
}
// Utilisation :
// Notez que "Début de la génération..." apparaîtra seulement à la première itération !
// Et "Je génère : X" — à chaque nouvelle itération.
foreach (var num in GenerateNumbers(3))
{
Console.WriteLine($"Reçu dans foreach : {num}");
}
yield break
Utilisé pour terminer l'itération prématurément. Après yield break, aucun autre élément ne sera retourné. Si l'exécution arrive à la fin de la méthode, un yield break explicite n'est pas nécessaire.
// Exemple 2.2 : Générateur avec condition de sortie
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
int count = 0;
foreach (var item in source)
{
if (count >= n)
{
yield break; // On sort du générateur
}
yield return item;
count++;
}
}
// Utilisation :
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
// Console.WriteLine(fruit); // Affichera "Apple", "Banana"
// }
3. Machine d'états
Comment cela fonctionne-t-il « sous le capot » ? Il n'y a pas de magie — juste un travail intelligent du compilateur.
Quand vous écrivez une méthode avec yield, le compilateur C# la transforme en une classe qui implémente IEnumerator<T> et IEnumerable<T>. Cette classe générée est une machine d'états.
- Sauvegarde d'état : la machine garde un numéro d'état (où elle s'est arrêtée) et les valeurs de toutes les variables locales au moment de la suspension.
- Itération : lors du parcours via foreach, les méthodes MoveNext() sont appelées et la propriété Current est lue. MoveNext() reprend l'exécution jusqu'au prochain yield return/yield break, et Current retourne l'élément courant.
En fait, le compilateur implémente pour vous le pattern Iterator.
4. Application des générateurs
Exemple : Traitement de gros volumes de données
Lecture d'un fichier ligne par ligne sans charger tout le fichier en mémoire.
// Simulation de lecture d'un gros fichier
IEnumerable<string> ReadBigFileLines(string filePath)
{
Console.WriteLine($"J'ouvre le fichier : {filePath}");
// Dans une vraie application il y aurait un StreamReader ici
yield return "Ligne de données 1";
yield return "Ligne de données 2";
yield return "Ligne de données 3";
Console.WriteLine("J'ai fini de simuler la lecture du fichier.");
}
// Utilisation :
Console.WriteLine("Début du traitement.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
Console.WriteLine($"Ligne traitée : {line}");
if (line.Contains("2")) break; // On peut s'arrêter quand on veut
}
Console.WriteLine("Traitement terminé.");
Remarquez que le message de fin n'apparaîtra qu'après la fin de l'itération.
Exemple : Séquences infinies
IEnumerable<long> FibonacciSequence()
{
long a = 0;
long b = 1;
while (true) // Séquence potentiellement infinie
{
yield return a;
long temp = a;
a = b;
b = temp + b;
}
}
// Utilisation :
int count = 0;
foreach (var num in FibonacciSequence())
{
Console.WriteLine(num);
count++;
if (count >= 10) break; // Il faut limiter pour ne pas bloquer
}
Exemple : Pipelines de traitement
Créer des chaînes de méthodes où chaque étape traite les données « à la volée ».
IEnumerable<int> GetNumbers()
{
yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}
IEnumerable<int> FilterEven(IEnumerable<int> source)
{
foreach (var num in source)
{
if (num % 2 == 0) yield return num;
}
}
IEnumerable<int> Square(IEnumerable<int> source)
{
foreach (var num in source)
{
yield return num * num;
}
}
// Utilisation :
foreach (var result in Square(FilterEven(GetNumbers())))
{
Console.WriteLine(result); // 4, 16
}
C'est très similaire à comment fonctionnent beaucoup d'opérateurs LINQ (par exemple, Where, Select, Take, Skip).
5. Générateurs asynchrones
Les générateurs synchrones sont excellents, mais que faire si chaque élément de la séquence nécessite une opération asynchrone (par exemple, une requête réseau) ? Avant C# 8.0, c'était difficile à réaliser.
Problème : flux de données asynchrones
On ne peut pas utiliser await dans une méthode générateur synchrone.
// Ceci NE COMPILERA PAS !
IEnumerable<string> GetStringsAsyncProblem()
{
await Task.Delay(100); // Erreur : await n'est permis que dans une méthode async
yield return "Hello";
}
Solution : IAsyncEnumerable<T> et await foreach
- IAsyncEnumerable<T> — l'équivalent asynchrone de IEnumerable<T>.
- await foreach — syntaxe pratique pour parcourir une séquence asynchrone (appelle MoveNextAsync() et gère l'état asynchrone).
async yield return
On peut maintenant utiliser yield return à l'intérieur d'une méthode async qui retourne IAsyncEnumerable<T>. Le compilateur construira une machine d'états asynchrone.
// Exemple 5.1 : Générateur asynchrone de nombres
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
Console.WriteLine("Début de la génération asynchrone...");
for (int i = 0; i < 5; i++)
{
await Task.Delay(100); // Simulation de travail asynchrone (par ex. requête réseau)
Console.WriteLine($"Je génère asynchronement : {i}");
yield return i; // On retourne l'élément
}
Console.WriteLine("Génération asynchrone terminée.");
}
// Utilisation :
async Task ConsumeAsyncNumbers()
{
Console.WriteLine("Début du traitement asynchrone...");
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"Reçu asynchronement : {number}");
}
Console.WriteLine("Traitement asynchrone terminé.");
}
// Lancement :
await ConsumeAsyncNumbers(); // Appelez ceci depuis un async Main ou un contexte similaire
IAsyncDisposable et await using (dans le contexte des générateurs)
Si le générateur ouvre une ressource qui se libère de façon asynchrone (DisposeAsync()), utilisez await using. À la fin d'un await foreach, DisposeAsync() sera automatiquement appelé sur l'itérateur interne s'il implémente IAsyncDisposable.
// Exemple 5.2 : Lecture asynchrone d'un fichier avec await using
// Le vrai StreamReader implémente IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
Console.WriteLine($"[Générateur] J'ouvre le fichier de façon asynchrone : {filePath}");
// await using garantit l'appel à DisposeAsync() après la sortie du bloc
await using var reader = new StreamReader(filePath);
string? line;
while ((line = await reader.ReadLineAsync()) != null) // Lecture asynchrone d'une ligne
{
yield return line;
}
Console.WriteLine($"[Générateur] J'ai fini de lire le fichier : {filePath}");
}
// Utilisation :
async Task ProcessFileAsync()
{
Console.WriteLine("[Handler] Début du traitement du fichier.");
await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // remplacez par un chemin réel
{
Console.WriteLine($"[Handler] Ligne reçue : {line}");
// Ici on peut faire un traitement asynchrone sur chaque ligne
await Task.Delay(50);
}
Console.WriteLine("[Handler] Traitement du fichier terminé.");
}
// Lancement :
await ProcessFileAsync(); // Appelez ceci depuis async Main
Annuler des générateurs asynchrones : CancellationToken
Ajoutez un CancellationToken aux générateurs asynchrones pour permettre à l'appelant d'annuler la génération.
// Exemple 5.3 : Générateur asynchrone annullable
async IAsyncEnumerable<int> GenerateCancelableSequence(
int start, int count,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
for (int i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested(); // On vérifie le token d'annulation
await Task.Delay(100, token); // Task.Delay supporte aussi l'annulation
yield return start + i;
}
}
Utilisation
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
await Task.Delay(300); // On laisse le générateur travailler un peu
cts.Cancel(); // On annule !
});
try
{
await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
{
Console.WriteLine($"Reçu : {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Génération annulée.");
}
6. Limitations et quand être prudent
Limitations de yield :
- On ne peut pas utiliser yield return dans des blocs try où catch ou finally contiennent aussi du yield.
- Les méthodes contenant yield ne peuvent pas être unsafe.
- On ne peut pas utiliser yield dans des méthodes async void (utilisez async Task ou IAsyncEnumerable<T>).
Performance : Pour des collections très petites ou fixes, le surcoût de la machine d'états peut être légèrement supérieur à celui d'un retour direct d'un List<T>. Mais pour de grandes quantités de données, le gain apporté par la paresse et le streaming est en général bien plus important.
Gestion des erreurs : Les exceptions lancées à l'intérieur d'un générateur remonteront correctement jusqu'au code appelant et peuvent y être interceptées comme dans des méthodes normales.
GO TO FULL VERSION