¿Cómo funcionan los cierres detrás de escena? (DO#)

Siento 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:

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

Normalmente, si {count} no fue capturado por el cierre, su ciclo de vida se ajustaría al método Counter () y, una vez que se complete, desaparecería con el rest de la asignación de stack para Counter (). ¿Qué sucede cuando está cerrado? ¿La asignación de la stack completa para esta llamada de Counter () se mantiene? ¿Copia {count} al montón? ¿Realmente nunca se asigna en la stack, pero el comstackdor 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.

El comstackdor (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 el tiempo de vida 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.

Tu tercera suposición es correcta. El comstackdor 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; } 

¿Tener sentido?

Además, solicitó comparaciones. VB y JScript crean cierres de la misma manera.

Gracias @HenkHolterman. Como Eric ya lo explicó, agregué el enlace solo para mostrar qué clase real genera el comstackdor para el cierre. Me gustaría agregar que la creación de clases de visualización mediante el comstackdor 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 comstackdor 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.

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

Aquí está 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í está lo que creo que sería el equivalente (si tenemos suerte Eric Lippert comentará 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 stack y se inicializa en consecuencia cada vez que se invoca el método de Contador. Normalmente, el marco de stack incluye una referencia a ‘esto’, más argumentos de método, más variables locales. (El marco de stack 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 stack capturado.

En base a esto, podemos usar el siguiente modelo mental: los cuadros de stack se mantienen en el montón (en lugar de en la stack), mientras que la stack en sí misma solo contiene punteros a los cuadros de stack que están en el montón. Los métodos Lambda contienen un puntero al marco de la stack. 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 comstackdor puede implementar esto usando solo el montón cuando se requiere el objeto Heap para soportar un cierre lambda.

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