1. Qu'est-ce qu'une closure ?
En programmation, une closure (closure) est une fonction qui capture des variables du contexte extérieur. Plus simplement, si une expression lambda ou une méthode anonyme utilise des variables déclarées en dehors de son corps, cette fonction devient une closure. Elle « se souvient » des valeurs de ces variables au moment de sa création.
Analogie de la vie quotidienne :
Imaginez que vous ayez noté une recette secrète sur un morceau de papier et l'ayez cachée dans une enveloppe. Même si le papier lui-même est perdu ou n'est plus accessible directement (la variable devient inaccessible directement), celui qui possède l'enveloppe (la lambda) a toujours accès à la recette.
Exemple simple :
int x = 42;
Func<int> getX = () => x;
Console.WriteLine(getX()); // 42
Ici getX est une closure, parce qu'elle utilise la variable x déclarée en dehors d'elle.
2. Pourquoi la capture de variables est importante ?
En C# les closures sont utilisées partout :
- Dans les collections et les requêtes LINQ
- Pour passer des paramètres dans des événements ou des méthodes asynchrones
- Lors de la création de handlers d'événements à l'intérieur de boucles
- Pour garder un « contexte » entre différents appels
Sans closures, beaucoup de pratiques standard en C# seraient impossibles ou extrêmement gênantes.
Exemple concret
Imaginons que nous développons une application de rappels : l'utilisateur crée une série de rappels, et un jour (dans une minute, une heure, une semaine...) l'application doit afficher le message approprié. Il est facile de passer au handler une lambda qui « a mémorisé » ce qu'il faut rappeler. C'est exactement la capture de variables — du classique.
3. Comment C# implémente la capture de variables
Sous le capot, C# fait un petit tour : quand vous avez une expression lambda qui utilise des variables externes, le compilateur crée automatiquement une classe auxiliaire — le display class. Toutes les variables « capturées » deviennent des champs de cette classe.
Schématiquement :
Variable externe ──► DisplayClass
▲
│
Closure (lambda)
Illustration en code
Voici ce qui se passe « sous le capot » :
int x = 5;
Func<int> f = () => x;
// Ici le compilateur fait à peu près ceci :
class DisplayClass
{
public int x;
public int Lambda() => x;
}
DisplayClass display = new DisplayClass();
display.x = 5;
Func<int> f = display.Lambda;
Ceci explique pourquoi la closure continue de voir la valeur actuelle de la variable, même après être sortie du bloc où elle a été déclarée.
4. La valeur de la variable est-elle « figée » ou changeante ?
En C#, les variables sont capturées par référence, pas par valeur. Ça signifie que si une expression lambda utilise une variable, et que cette variable est modifiée ailleurs, la lambda verra la nouvelle valeur.
Exemple :
int x = 10;
Func<int> getX = () => x;
x = 20;
Console.WriteLine(getX()); // 20, pas 10 !
Les étudiants s'attendent souvent à ce que getX() retourne toujours 10, parce que la variable est « capturée ». En réalité, la lambda lit la variable qui existe toujours et peut être modifiée.
Quand la valeur est-elle tout de même figée ?
Si la variable est déclarée dans une boucle avec une nouvelle portée pour chaque itération, par exemple avec foreach, et qu'une nouvelle variable est créée à chaque itération — la lambda « retiendra » la valeur courante.
5. Exemples : closure dans une boucle — piège typique
Erreur fréquente
On veut créer un tableau de delegates, chacun devant afficher son propre numéro de la boucle :
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var action in actions)
action();
Que va afficher le programme ?
5
5
5
5
5
Oh ! Pourquoi pas 0,1,2,3,4 ?
Raison :
La lambda capture une seule et même variable i, qui continue de changer pendant la boucle. Quand vous appellerez plus tard les delegates, i vaut déjà 5.
Comment faire correctement ?
Il faut créer une variable distincte dans le corps de la boucle :
Action[] actions = new Action[5];
for (int i = 0; i < 5; i++)
{
int index = i; // Nouvelle variable pour chaque itération !
actions[i] = () => Console.WriteLine(index);
}
foreach (var action in actions)
action();
Maintenant le programme affichera :
0
1
2
3
4
Comment cela se relie au display class ?
Dans la première version, tous les delegates « accrochent » le même champ — d'où le résultat identique. Dans la deuxième version, une nouvelle variable locale est créée pour chaque itération, donc pour chaque delegate un DisplayClass distinct est formé avec une valeur unique.
6. Scénarios pratiques d'utilisation de la capture de variables
Exemple 1 : gestion d'événements avec « contexte »
Supposons que dans notre petite application il y a une liste de tâches, et qu'un handler soit attaché à chaque bouton « complete ». Il faut que la lambda à l'intérieur du handler « se souvienne » quelle tâche traiter :
foreach (var task in tasks)
{
button.Click += (sender, e) => CompleteTask(task);
}
Ici la variable task est capturée à chaque itération. Il est important de s'assurer qu'elle est correctement déclarée dans la boucle pour ne pas tomber dans le piège vu plus haut.
Exemple 2 : opérations asynchrones
Souvent les closures sont utilisées pour passer des paramètres dans la logique asynchrone — par exemple, sauvegarder une variable dans un « slot » local au lancement d'une tâche asynchrone :
for (int i = 0; i < 3; i++)
{
int index = i; // Obligatoire !
Task.Run(() => Console.WriteLine($"Task #{index}"));
}
Sans la variable locale, toutes les tâches imprimeraient le même numéro, ce qui n'est généralement pas ce qu'on veut.
Exemple 3 : requêtes LINQ
LINQ sur les collections utilise souvent des closures pour filtrer ou transformer des éléments en tenant compte de variables externes. Par exemple :
string prefix = "Task";
var filtered = tasks.Where(t => t.Name.StartsWith(prefix));
Ici la lambda dans Where a mémorisé la valeur de prefix et appelle la méthode StartsWith.
7. Particularités, limites et erreurs courantes avec les closures
Erreur n°1 : utiliser la même variable de boucle pour tous les delegates.
Si dans une boucle tous les delegates référencent la même variable, le résultat sera inattendu. Il est important de créer une nouvelle variable locale à l'intérieur de la boucle pour chaque delegate afin d'éviter la référence partagée.
Erreur n°2 : closures sur des variables hors de la méthode.
Si une closure capture un champ de classe ou une variable déclarée en dehors de la méthode courante, elle gardera une référence à cette variable. Cela peut conduire à des fuites de mémoire, car le garbage collector ne pourra pas libérer l'objet tant qu'il y a des références depuis des closures.
Erreur n°3 : delegates longue durée avec closures.
Si un delegate contenant une closure est conservé longtemps (par exemple dans un champ statique), les variables auxquelles il réfère restent aussi en mémoire plus longtemps que prévu. Cela cause souvent des fuites mémoire cachées et des problèmes de performance.
GO TO FULL VERSION