2009-12-18 3 views
43

Sento di avere una comprensione abbastanza decente delle chiusure, di come usarle e quando possono essere utili. Ma quello che non capisco è come funzionano effettivamente dietro le quinte nella memoria. Qualche esempio di codice:Come funzionano le chiusure dietro le quinte? (C#)

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

    return counter; 
} 

Normalmente, se {count} non è stato catturato dalla chiusura, il suo ciclo di vita sarebbe ambito al contatore() metodo, ed una volta completata sarebbe andata via con il resto della pila allocazione per Counter(). Cosa succede però quando è chiuso? L'intera allocazione dello stack per questa chiamata di Counter() rimane invariata? Copia {count} nell'heap? Non viene mai effettivamente allocato nello stack, ma riconosciuto dal compilatore come se fosse chiuso e quindi vive sempre nell'heap?

Per questa particolare domanda, sono principalmente interessato a come funziona in C#, ma non mi oppongo al confronto con altre lingue che supportano le chiusure.

+0

Ottima domanda. Non sono sicuro, ma sì, puoi mantenere il frame dello stack in C#. I generatori lo usano sempre (cosa LINQ per le strutture dati) che si basano sulla resa sotto il cofano. Spero di non essere fuori luogo. se lo sarò, imparerò molto. –

+2

yield trasforma il metodo in una classe separata con una macchina a stati. Lo stack in sé non viene tenuto in giro, ma lo stato dello stack viene spostato nello stato di classe in una classe generata dal compilatore – thecoop

+0

@thecoop, hai un link che spieghi questo per favore? –

risposta

31

Il compilatore (in contrasto con il runtime) crea un'altra classe/tipo. La funzione con la chiusura e tutte le variabili che hai chiuso/sollevato/catturato vengono riscritte in tutto il codice come membri di quella classe. Una chiusura in .Net è implementata come un'istanza di questa classe nascosta.

Ciò significa che la variabile count è un membro di una classe diversa interamente e la durata di tale classe funziona come qualsiasi altro oggetto clr; non è idoneo per la garbage collection finché non è più rootato. Ciò significa che finché hai un riferimento chiamabile al metodo non sta andando da nessuna parte.

+4

Ispeziona il codice in questione con Reflector per vedere un esempio di questo – Greg

+5

... basta cercare la classe di nome più brutta nella tua soluzione. – Will

+2

Significa che una chiusura comporterà una nuova allocazione dell'heap, anche se il valore che viene chiuso è primitivo? – Matt

45

La terza ipotesi è corretta. Il compilatore genererà un codice come questo:

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; 
} 

Ha senso?

Inoltre, hai chiesto dei confronti. Sia VB che JScript creano entrambe le chiusure praticamente nello stesso modo.

+14

Perché dovremmo crederci? Oh giusto ... – ChaosPandion

0

Grazie a @HenkHolterman. Poiché è stato già spiegato da Eric, ho aggiunto il collegamento solo per mostrare quale classe effettiva il compilatore genera per la chiusura. Vorrei aggiungere che la creazione di classi di visualizzazione da parte del compilatore C# può portare a perdite di memoria. Ad esempio, all'interno di una funzione esiste una variabile int acquisita da un'espressione lambda e un'altra variabile locale che contiene semplicemente un riferimento a un array di byte di grandi dimensioni. Il compilatore creerebbe un'istanza di classe di visualizzazione che manterrà i riferimenti a entrambe le variabili i.e. int e l'array di byte. Ma la matrice di byte non sarà raccolta di dati inutili finché non viene referenziato il lambda.

0

La risposta di Eric Lippert colpisce davvero il punto. Comunque sarebbe bello costruire un'immagine di come i frame di stack e le acquisizioni funzionino in generale. Per fare ciò aiuta a guardare un esempio leggermente più complesso.

ecco il codice cattura:

public class Scorekeeper { 
    int swish = 7; 

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

e qui è quello che penso l'equivalente sarebbe (se siamo fortunati Eric Lippert sarà commentare se questo è in realtà corretto o meno):

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; 
    } 
} 

Il punto è che la classe locale sostituisce l'intero frame dello stack e viene inizializzata di conseguenza ogni volta che viene richiamato il metodo Counter. In genere la cornice dello stack include un riferimento a "questo", oltre agli argomenti del metodo, oltre alle variabili locali. (Anche il frame di stack viene esteso quando viene inserito un blocco di controllo.)

Di conseguenza non abbiamo un solo oggetto corrispondente al contesto catturato, ma in realtà abbiamo un oggetto per ogni frame di stack catturato.

In base a questo, possiamo usare il seguente modello mentale: i frame di stack vengono mantenuti nell'heap (anziché nello stack), mentre lo stack stesso contiene solo i puntatori ai frame di stack presenti nell'heap. I metodi Lambda contengono un puntatore al frame dello stack. Questo viene fatto usando la memoria gestita, quindi il frame rimane nell'heap fino a quando non è più necessario.

Ovviamente il compilatore può implementare questo utilizzando solo l'heap quando l'oggetto heap è richiesto per supportare una chiusura lambda.

Quello che mi piace di questo modello è che fornisce un'immagine integrata per "rendimento". Possiamo pensare a un metodo iteratore (usando yield return) come se fosse stato creato lo stack frame sull'heap e il puntatore di riferimento memorizzato in una variabile locale nel chiamante, da usare durante l'iterazione.

+0

Non è corretto; come si può accedere a 'swish' privati ​​dall'esterno 'Scorekeeper'? Cosa succede se 'start' è mutato? Ma più precisamente: qual è il valore nel rispondere a una domanda di otto anni con una risposta accettata? –

+0

Se vuoi sapere qual è il vero codegen, usa ILDASM o un disassemblatore IL-to-source. –

+0

Un modo migliore di pensarci è smettere di pensare a "stack frames" come qualcosa di fondamentale. Lo stack è semplicemente una struttura dati che viene utilizzata per implementare due cose: ** attivazione ** e ** continuazione **. Ovvero: quali sono i valori associati all'attivazione di un metodo e quale codice verrà eseguito dopo il ritorno di questo metodo? Ma lo stack è solo una struttura dati adatta per memorizzare le informazioni di attivazione/continuazione ** se le vite di attivazione del metodo logicamente formano uno stack **. –