45 votes

Comment les fermetures fonctionnent-elles en coulisses ? (C#)

Je pense avoir une bonne compréhension des fermetures, de leur utilisation et de leur utilité. Mais ce que je ne comprends pas, c'est comment elles fonctionnent réellement dans les coulisses de la mémoire. Quelques exemples de code :

public Action Counter()
{
    int count = 0;
    Action counter = () =>
    {
        count++;
    };

    return counter;
}

Normalement, si {count} n'a pas été capturé par la fermeture, son cycle de vie devrait s'étendre à la méthode Counter(), et une fois celle-ci terminée, il devrait disparaître avec le reste de l'allocation de la pile pour Counter(). Mais que se passe-t-il lorsque la méthode est fermée ? Est-ce que toute l'allocation de la pile pour cet appel de Counter() reste en place ? Copie-t-elle {count} dans le tas ? Est-ce qu'il n'est jamais alloué sur la pile, mais reconnu par le compilateur comme étant fermé et donc toujours présent sur le tas ?

Pour cette question particulière, je suis principalement intéressé par la façon dont cela fonctionne en C#, mais je ne serais pas opposé à des comparaisons avec d'autres langages qui supportent les fermetures.

47voto

Eric Lippert Points 300275

Votre troisième hypothèse est correcte. Le compilateur va générer un code comme celui-ci :

private class Locals
{
  public int count;
  public void Anonymous()
  {
    this.count++;
  }
}

public Action Counter()
{
  Locals locals = new Locals();
  locals.count = 0;
  Action counter = new Action(locals.Anonymous);
  return counter;
}

C'est logique ?

Par ailleurs, vous avez demandé des comparaisons. VB et JScript créent tous deux des fermetures à peu près de la même manière.

33voto

Joel Coehoorn Points 190579

El compilateur (par opposition au runtime) crée une autre classe/un autre type. La fonction avec votre fermeture et toutes les variables sur lesquelles vous avez fermé, empoché ou capturé sont réécrites dans tout votre code comme des membres de cette classe. Une fermeture dans .Net est implémentée comme une instance de cette classe cachée.

Cela signifie que votre variable de comptage est membre d'une classe entièrement différente, et la durée de vie de cette classe fonctionne comme n'importe quel autre objet clr ; elle n'est pas éligible à la collecte des déchets jusqu'à ce qu'elle ne soit plus enracinée. Cela signifie que tant que vous avez une référence appelable à la méthode, elle ne va nulle part.

0voto

Brikesh Kumar Points 69

Merci @HenkHolterman. Puisque cela a déjà été expliqué par Eric, j'ai ajouté le lien juste pour montrer quelle classe réelle le compilateur génère pour la fermeture. Je voudrais ajouter que la création de classes d'affichage par le compilateur C# peut conduire à des fuites de mémoire. Par exemple, dans une fonction, il y a une variable int qui est capturée par une expression lambda et une autre variable locale qui contient simplement une référence à un grand tableau d'octets. Le compilateur créera une instance de classe d'affichage qui contiendra les références aux deux variables, à savoir int et le tableau d'octets. Mais le tableau d'octets ne sera pas collecté tant que l'expression lambda ne sera pas référencée.

0voto

sjb-sjb Points 539

La réponse d'Eric Lippert va droit au but. Cependant, il serait bon de se faire une idée de la façon dont les cadres de pile et les captures fonctionnent en général. Pour ce faire, il est utile d'examiner un exemple légèrement plus complexe.

Voici le code de capture :

public class Scorekeeper { 
   int swish = 7; 

   public Action Counter(int start)
   {
      int count = 0;
      Action counter = () => { count += start + swish; }
      return counter;
   }
}

Et voici ce que je pense être l'équivalent (si nous avons de la chance, Eric Lippert nous dira si cela est correct ou non) :

private class Locals
{
  public Locals( Scorekeeper sk, int st)
  { 
      this.scorekeeper = sk;
      this.start = st;
  } 

  private Scorekeeper scorekeeper;
  private int start;

  public int count;

  public void Anonymous()
  {
    this.count += start + scorekeeper.swish;
  }
}

public class Scorekeeper {
    int swish = 7;

    public Action Counter(int start)
    {
      Locals locals = new Locals(this, start);
      locals.count = 0;
      Action counter = new Action(locals.Anonymous);
      return counter;
    }
}

Le fait est que la classe locale se substitue à l'ensemble de la pile et est initialisée en conséquence chaque fois que la méthode Counter est invoquée. En général, le cadre de la pile comprend une référence à 'this', plus les arguments de la méthode, plus les variables locales. (Le cadre de la pile est aussi en fait étendu lorsqu'un bloc de contrôle est entré).

Par conséquent, nous ne disposons pas d'un seul objet correspondant au contexte capturé, mais plutôt d'un objet par trame de pile capturée.

Sur cette base, nous pouvons utiliser le modèle mental suivant : les trames de pile sont conservées sur le tas (au lieu d'être sur la pile), tandis que la pile elle-même ne contient que des pointeurs vers les trames de pile qui sont sur le tas. Les méthodes lambda contiennent un pointeur vers le cadre de pile. Ceci est fait en utilisant de la mémoire gérée, de sorte que le cadre reste sur le tas jusqu'à ce qu'il ne soit plus nécessaire.

Évidemment, le compilateur peut implémenter ceci en n'utilisant le tas que lorsque l'objet tas est nécessaire pour supporter une fermeture lambda.

Ce que j'aime dans ce modèle, c'est qu'il fournit une image intégrée du "rendement". Nous pouvons penser à une méthode d'itérateur (utilisant le rendement de retour) comme si son cadre de pile était créé sur le tas et le pointeur de référencement stocké dans une variable locale dans l'appelant, pour être utilisé pendant l'itération.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X