c# lambda closures .net-4.6

¿Este comportamiento de combinación de cierre es un error del compilador de C#?



lambda closures (2)

Estaba investigando algunos problemas de vida de objetos extraños, y me encontré con este comportamiento tan desconcertante del compilador de C #:

Considere la siguiente clase de prueba:

class Test { delegate Stream CreateStream(); CreateStream TestMethod( IEnumerable<string> data ) { string file = "dummy.txt"; var hashSet = new HashSet<string>(); var count = data.Count( s => hashSet.Add( s ) ); CreateStream createStream = () => File.OpenRead( file ); return createStream; } }

El compilador genera lo siguiente:

internal class Test { public Test() { base..ctor(); } private Test.CreateStream TestMethod(IEnumerable<string> data) { Test.<>c__DisplayClass1_0 cDisplayClass10 = new Test.<>c__DisplayClass1_0(); cDisplayClass10.file = "dummy.txt"; cDisplayClass10.hashSet = new HashSet<string>(); Enumerable.Count<string>(data, new Func<string, bool>((object) cDisplayClass10, __methodptr(<TestMethod>b__0))); return new Test.CreateStream((object) cDisplayClass10, __methodptr(<TestMethod>b__1)); } private delegate Stream CreateStream(); [CompilerGenerated] private sealed class <>c__DisplayClass1_0 { public HashSet<string> hashSet; public string file; public <>c__DisplayClass1_0() { base..ctor(); } internal bool <TestMethod>b__0(string s) { return this.hashSet.Add(s); } internal Stream <TestMethod>b__1() { return (Stream) File.OpenRead(this.file); } } }

La clase original contiene dos lambdas: s => hashSet.Add( s ) y () => File.OpenRead( file ) . El primero se cierra sobre la variable local hashSet , el segundo se cierra sobre el file variable local. Sin embargo, el compilador genera una única clase de implementación de cierre <>c__DisplayClass1_0 que contiene tanto el hashSet como el file . Como consecuencia, el delegado de CreateStream devuelto contiene y mantiene viva una referencia al objeto hashSet que debería haber estado disponible para GC una vez que se devolvió TestMethod .

En el escenario real donde he encontrado este problema, un objeto muy importante (es decir,> 100 mb) está encerrado incorrectamente.

Mis preguntas específicas son:

  1. ¿Es esto un error? Si no es así, ¿por qué se considera deseable este comportamiento?

Actualizar:

La especificación C # 5 7.15.5.1 dice:

Cuando una función anónima hace referencia a una variable externa, se dice que la función anónima ha capturado la variable externa. Normalmente, el tiempo de vida de una variable local se limita a la ejecución del bloque o declaración con la que está asociada (§5.1.7). Sin embargo, la vida útil de una variable externa capturada se extiende al menos hasta que el delegado o el árbol de expresiones creado a partir de la función anónima se convierta en elegible para la recolección de basura.

Esto parece estar abierto a cierto grado de interpretación, y no prohíbe explícitamente que un lambda capture variables a las que no hace referencia. Sin embargo, esta pregunta cubre un escenario relacionado, que @ eric-lippert consideró un error. En mi humilde opinión, veo la implementación de cierre combinado proporcionada por el compilador como una buena optimización, pero que la optimización no debe usarse para las lambdas que el compilador puede detectar razonablemente puede tener una vida útil más allá del marco de pila actual.

  1. ¿Cómo puedo codificar contra esto sin abandonar el uso de las lambdas en conjunto? En particular, ¿cómo puedo codificar contra esto de manera defensiva, de modo que los futuros cambios en el código no causen repentinamente que algún otro lambda sin cambios en el mismo método comience a incluir algo que no debería?

Actualizar:

El ejemplo del código que he proporcionado es por necesidad creado. Claramente, refactorizar la creación de lambda a un método separado funciona en torno al problema. Mi pregunta no pretende ser sobre las mejores prácticas de diseño (como también las cubre @ peter-duniho). Más bien, dado el contenido del TestMethod tal como está, me gustaría saber si hay alguna forma de obligar al compilador a excluir el createStream lambda de la implementación de cierre combinado.

Para el registro, estoy apuntando a .NET 4.6 con VS 2015.


¿Es esto un error?

No. El compilador cumple con la especificación aquí.

¿Por qué se considera deseable este comportamiento?

No es deseable. Es profundamente desafortunado, como descubrieron aquí, y como describí en 2007:

http://blogs.msdn.com/b/ericlippert/archive/2007/06/06/fyi-c-and-vb-closures-are-per-scope.aspx

El equipo del compilador de C # ha considerado arreglar esto en todas las versiones desde C # 3.0 y nunca ha tenido la prioridad suficiente. Considere la posibilidad de ingresar un problema en el sitio de Github de Roslyn (si no lo hay ya; puede haberlo).

Personalmente me gustaría ver esto arreglado; Tal como está, es un gran "gotcha".

¿Cómo puedo codificar contra esto sin abandonar el uso de las lambdas en conjunto?

La variable es lo que se captura. Podría establecer la variable hashset en null cuando haya terminado con ella. Entonces, la única memoria que se consume es la memoria para la variable, cuatro bytes, y no la memoria para la cosa a la que se refiere, que se recopilará.


No tengo conocimiento de nada en la especificación del lenguaje C # que dicte exactamente cómo un compilador implementa métodos anónimos y captura de variables. Este es un detalle de implementación.

Lo que sí hace la especificación es establecer algunas reglas sobre cómo deben comportarse los métodos anónimos y sus variables de captura. No tengo una copia de la especificación C # 6, pero aquí hay un texto relevante de la especificación C # 5, bajo "7.15.5.1 Variables externas capturadas":

... la vida útil de una variable externa capturada se extiende al menos hasta que el delegado o el árbol de expresión creado a partir de la función anónima sea elegible para la recolección de basura. [énfasis mío]

No hay nada en la especificación que limite la vida útil de la variable. Simplemente se requiere que el compilador se asegure de que la variable viva el tiempo suficiente para que siga siendo válida si el método anónimo lo necesita.

Asi que…

1. ¿Es esto un error? Si no es así, ¿por qué se considera deseable este comportamiento?

No es un error. El compilador cumple con la especificación.

En cuanto a si se considera "deseable", ese es un término cargado. Lo que es "deseable" depende de tus prioridades. Dicho esto, una de las prioridades de un autor del compilador es simplificar la tarea del compilador (y al hacerlo, hacerlo correr más rápido y reducir las posibilidades de errores). Esta implementación en particular podría considerarse "deseable" en ese contexto.

Por otro lado, los diseñadores de lenguajes y los autores de compiladores también tienen el objetivo compartido de ayudar a los programadores a producir código de trabajo. En la medida en que un detalle de implementación puede interferir con esto, dicho detalle de implementación podría considerarse "indeseable". En última instancia, es una cuestión de cómo se clasifican cada una de esas prioridades, de acuerdo con sus objetivos potencialmente competitivos.

2. ¿Cómo puedo codificar contra esto sin abandonar el uso de las lambdas juntas? En particular, ¿cómo puedo codificar contra esto de manera defensiva, de modo que los futuros cambios en el código no causen repentinamente que algún otro lambda sin cambios en el mismo método comience a incluir algo que no debería?

Difícil de decir sin un ejemplo menos artificial. En general, diría que la respuesta obvia es "no mezcle sus lambdas de esa manera". En su ejemplo particular (admitido), usted tiene un método que parece estar haciendo dos cosas completamente diferentes . Esto generalmente está mal visto por una variedad de razones, y me parece que este ejemplo se suma a esa lista.

No sé cuál sería la mejor manera de arreglar las "dos cosas diferentes", pero una alternativa obvia sería al menos refactorizar el método para que el método de "dos cosas diferentes" sea delegar el trabajo a otros dos métodos. cada nombre se describe de forma descriptiva (lo que tiene el beneficio adicional de ayudar a que el código se auto-documente).

Por ejemplo:

CreateStream TestMethod( IEnumerable<string> data ) { string file = "dummy.txt"; var hashSet = new HashSet<string>(); var count = AddAndCountNewItems(data, hashSet); CreateStream createStream = GetCreateStreamCallback(file); return createStream; } int AddAndCountNewItems(IEnumerable<string> data, HashSet<string> hashSet) { return data.Count( s => hashSet.Add( s ) ); } CreateStream GetCreateStreamCallback(string file) { return () => File.OpenRead( file ); }

De esta manera, las variables capturadas permanecen independientes. Incluso si el compilador, por alguna extraña razón, aún los coloca en el mismo tipo de cierre, todavía no debería resultar en la misma instancia de ese tipo utilizado entre los dos cierres.

Su TestMethod() todavía hace dos cosas diferentes, pero al menos no contiene esas dos implementaciones no relacionadas. El código es más legible y está mejor dividido en compartimentos, lo que es bueno incluso por el hecho de que soluciona el problema de la duración de la variable.