2009-12-18 13 views
43

Creo que tengo una comprensión bastante decente de los cierres, cómo usarlos y cuándo pueden ser útiles. Pero lo que no entiendo es cómo funcionan realmente detrás de escena en la memoria. Un código de ejemplo:¿Cómo funcionan los cierres detrás de escena? (C#)

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

    return counter; 
} 

Normalmente, si {count} no fue capturado por el cierre, su ciclo de vida sería como alcance el método Contador(), y después de que se complete se iría con el resto de la pila asignación para Counter(). ¿Qué sucede cuando está cerrado? ¿La asignación de la pila completa para esta llamada de Counter() se mantiene? ¿Copia {count} al montón? ¿Realmente nunca se asigna en la pila, pero el compilador lo reconoce como cerrado y, por lo tanto, siempre vive en el montón?

Para esta pregunta en particular, estoy interesado principalmente en cómo funciona esto en C#, pero no me opondría a las comparaciones con otros lenguajes que admiten cierres.

+0

Una gran pregunta. No estoy seguro, pero sí, puedes mantener el marco de la pila en C#. Los generadores lo usan todo el tiempo (cosa LINQ para estructuras de datos) que dependen del rendimiento bajo el capó. Con suerte no estoy fuera de lugar. si lo estoy, aprenderé mucho. –

+2

rendimiento convierte el método en una clase separada con una máquina de estado. La pila en sí misma no se mantiene, pero el estado de pila se mueve al estado de clase en una clase generada por el compilador – thecoop

+0

@thecoop, ¿tiene un enlace que explique esto por favor? –

Respuesta

31

El compilador (a diferencia del tiempo de ejecución) crea otra clase/tipo. La función con su cierre y las variables que cerró/izó/capturó se vuelven a escribir a lo largo de su código como miembros de esa clase. Un cierre en .Net se implementa como una instancia de esta clase oculta.

Eso significa que su variable de conteo es un miembro de una clase diferente por completo, y la vida útil de esa clase funciona como cualquier otro objeto clr; no es elegible para la recolección de basura hasta que ya no esté rooteado. Eso significa que siempre que tengas una referencia invocable al método no irá a ninguna parte.

+4

Inspeccione el código en cuestión con Reflector para ver un ejemplo de este – Greg

+5

... solo busque la clase más fea nombrada en tu solución. – Will

+2

¿Eso significa que un cierre dará como resultado una nueva asignación de montón, incluso si el valor que se cierra es primitivo? – Matt

45

Su tercera suposición es correcta. El compilador generará un código como este:

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

¿Tiene sentido?

Además, ha pedido comparaciones. VB y JScript crean cierres de la misma manera.

+14

¿Por qué deberíamos creerte? Oh, eso es correcto ... – ChaosPandion

0

Gracias @HenkHolterman. Como Eric ya lo explicó, agregué el enlace solo para mostrar qué clase real genera el compilador para el cierre. Me gustaría agregar que la creación de clases de visualización mediante el compilador de C# puede provocar pérdidas de memoria. Por ejemplo, dentro de una función hay una variable int que es capturada por una expresión lambda y hay otra variable local que simplemente contiene una referencia a una matriz de bytes grande. El compilador crearía una instancia de clase de pantalla que contendrá las referencias a ambas variables, es decir, int y la matriz de bytes. Pero la matriz de bytes no será recogida de basura hasta que se haga referencia a la lambda.

0

La respuesta de Eric Lippert realmente llega al punto. Sin embargo, sería bueno construir una imagen de cómo funcionan los marcos de pila y las capturas en general. Hacer esto ayuda a mirar un ejemplo un poco más complejo.

Aquí es el código de captura:

public class Scorekeeper { 
    int swish = 7; 

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

Y aquí es lo que yo creo que el equivalente sería (si tenemos suerte Eric Lippert comentarios sobre si esto es realmente correcto o no):

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

El punto es que la clase local sustituye a todo el marco de pila y se inicializa en consecuencia cada vez que se invoca el método de Contador. Normalmente, el marco de pila incluye una referencia a 'esto', más argumentos de método, más variables locales. (El marco de pila también se extiende cuando se ingresa un bloque de control.)

En consecuencia, no tenemos un solo objeto que corresponda al contexto capturado, sino que tenemos un objeto por marco de pila capturado.

En base a esto, podemos usar el siguiente modelo mental: los marcos de pila se mantienen en el montón (en lugar de en la pila), mientras que la pila solo contiene punteros a los marcos de pila que están en el montón. Los métodos Lambda contienen un puntero al marco de la pila. Esto se hace utilizando la memoria administrada, por lo que el marco se queda en el montón hasta que ya no se necesita.

Obviamente, el compilador puede implementar esto utilizando solo el montón cuando se requiere el objeto Heap para admitir un cierre lambda.

Lo que me gusta de este modelo es que proporciona una imagen integrada para 'rendimiento de retorno'. Podemos pensar en un método de iterador (utilizando retorno de rendimiento) como si fuera un marco de pila creado en el montón y el puntero de referencia almacenado en una variable local en el llamador, para usarlo durante la iteración.

+0

No es correcto; ¿Cómo se puede acceder al '' swish'' privado desde la clase externa 'Scorekeeper'? ¿Qué sucede si 'start' está mutado? Pero más al grano: ¿cuál es el valor de responder una pregunta de ocho años con una respuesta aceptada? –

+0

Si quiere saber cuál es el codegen real, use ILDASM o un desensamblador IL-a-fuente. –

+0

Una manera mejor de pensarlo es dejar de pensar en los "marcos de pila" como algo fundamental. La pila es simplemente una estructura de datos que se usa para implementar dos cosas: ** activación ** y ** continuación **. Es decir: ¿cuáles son los valores asociados con la activación de un método y qué código se ejecutará después de que este método regrese? Pero la pila es solo una estructura de datos adecuada para almacenar la información de activación/continuación ** si las vidas útiles de activación del método forman lógicamente una pila **. –

Cuestiones relacionadas