¿Cómo puedo determinar de manera confiable el tipo de variable que se declara usando var en tiempo de diseño?

109

Estoy trabajando en una instalación de finalización (intellisense) para C # en emacs.

La idea es que, si un usuario escribe un fragmento y luego solicita la finalización mediante una combinación de teclas en particular, la función de finalización utilizará la reflexión .NET para determinar las posibles finalizaciones.

Hacer esto requiere que se conozca el tipo de cosa que se está completando. Si es una cadena, hay un conjunto conocido de posibles métodos y propiedades; si es un Int32, tiene un conjunto separado y así sucesivamente.

Usando semántica, un paquete de código lexer / parser disponible en emacs, puedo localizar las declaraciones de variables y sus tipos. Dado eso, es sencillo usar la reflexión para obtener los métodos y propiedades del tipo, y luego presentar la lista de opciones al usuario. (Ok, no es muy sencillo de hacer dentro de emacs, pero usando la capacidad de ejecutar un proceso de PowerShell dentro de emacs , se vuelve mucho más fácil. Escribo un ensamblaje .NET personalizado para hacer la reflexión, lo cargo en el PowerShell y luego elisp se ejecuta dentro emacs puede enviar comandos a powershell y leer respuestas, a través de comint. Como resultado, emacs puede obtener los resultados de la reflexión rápidamente).

El problema llega cuando el código se usa varen la declaración de lo que se está completando. Eso significa que el tipo no se especifica explícitamente y la finalización no funcionará.

¿Cómo puedo determinar de manera confiable el tipo real utilizado, cuando la variable se declara con la varpalabra clave? Para que quede claro, no necesito determinarlo en tiempo de ejecución. Quiero determinarlo en "Tiempo de diseño".

Hasta ahora tengo estas ideas:

  1. compilar e invocar:
    • extraer la declaración de declaración, por ejemplo, `var foo =" un valor de cadena ";`
    • concatenar una sentencia `foo.GetType ();`
    • compilar dinámicamente el C # resultante, fragmentarlo en un nuevo ensamblado
    • cargue el ensamblado en un nuevo AppDomain, ejecute el fragmento y obtenga el tipo de retorno.
    • descargar y desechar el conjunto

    Sé cómo hacer todo esto. Pero suena terriblemente pesado, para cada solicitud de finalización en el editor.

    Supongo que no necesito un AppDomain nuevo cada vez. Podría reutilizar un solo AppDomain para múltiples ensamblajes temporales y amortizar el costo de configurarlo y desmontarlo, en múltiples solicitudes de finalización. Eso es más una modificación de la idea básica.

  2. compilar e inspeccionar IL

    Simplemente compile la declaración en un módulo y luego inspeccione el IL para determinar el tipo real que fue inferido por el compilador. ¿Cómo sería esto posible? ¿Qué usaría para examinar el IL?

¿Alguna idea mejor por ahí? Comentarios sugerencias?


EDITAR : pensando en esto más a fondo, compilar e invocar no es aceptable, porque la invocación puede tener efectos secundarios. Por tanto, hay que descartar la primera opción.

Además, creo que no puedo asumir la presencia de .NET 4.0.


ACTUALIZACIÓN : la respuesta correcta, que no se mencionó anteriormente, pero que Eric Lippert señaló con delicadeza, es implementar un sistema de inferencia de tipo de fidelidad total. Es la única forma de determinar de forma fiable el tipo de var en tiempo de diseño. Pero tampoco es fácil de hacer. Debido a que no me hago ilusiones de querer intentar construir tal cosa, tomé el atajo de la opción 2: extraer el código de declaración relevante y compilarlo, luego inspeccionar el IL resultante.

Esto realmente funciona, para un subconjunto justo de escenarios de finalización.

Por ejemplo, suponga que en los siguientes fragmentos de código, el? es la posición en la que el usuario solicita la finalización. Esto funciona:

var x = "hello there"; 
x.?

La finalización se da cuenta de que x es una cadena y proporciona las opciones adecuadas. Lo hace generando y luego compilando el siguiente código fuente:

namespace N1 {
  static class dmriiann5he { // randomly-generated class name
    static void M1 () {
       var x = "hello there"; 
    }
  }
}

... y luego inspeccionando el IL con una simple reflexión.

Esto también funciona:

var x = new XmlDocument();
x.? 

El motor agrega las cláusulas using apropiadas al código fuente generado, de modo que se compile correctamente, y luego la inspección de IL es la misma.

Esto también funciona:

var x = "hello"; 
var y = x.ToCharArray();    
var z = y.?

Simplemente significa que la inspección de IL tiene que encontrar el tipo de la tercera variable local, en lugar de la primera.

Y esto:

var foo = "Tra la la";
var fred = new System.Collections.Generic.List<String>
    {
        foo,
        foo.Length.ToString()
    };
var z = fred.Count;
var x = z.?

... que es solo un nivel más profundo que el ejemplo anterior.

Pero lo que no funciona es la finalización de cualquier variable local cuya inicialización dependa en cualquier punto de un miembro de instancia o argumento del método local. Me gusta:

var foo = this.InstanceMethod();
foo.?

Ni la sintaxis LINQ.

Tendré que pensar en lo valiosas que son esas cosas antes de considerar abordarlas a través de lo que definitivamente es un "diseño limitado" (palabra cortés para hack) para completar.

Un enfoque para abordar el problema de las dependencias de los argumentos del método o de los métodos de instancia sería reemplazar, en el fragmento de código que se genera, se compila y luego se analiza por IL, las referencias a esas cosas con vars locales "sintéticas" del mismo tipo.


Otra actualización : la finalización en vars que dependen de los miembros de la instancia, ahora funciona.

Lo que hice fue interrogar el tipo (mediante semántica) y luego generar miembros sustitutos sintéticos para todos los miembros existentes. Para un búfer de C # como este:

public class CsharpCompletion
{
    private static int PrivateStaticField1 = 17;

    string InstanceMethod1(int index)
    {
        ...lots of code here...
        return result;
    }

    public void Run(int count)
    {
        var foo = "this is a string";
        var fred = new System.Collections.Generic.List<String>
        {
            foo,
            foo.Length.ToString()
        };
        var z = fred.Count;
        var mmm = count + z + CsharpCompletion.PrivateStaticField1;
        var nnn = this.InstanceMethod1(mmm);
        var fff = nnn.?

        ...more code here...

... el código generado que se compila, para que pueda aprender del IL de salida el tipo de var nnn local, se ve así:

namespace Nsbwhi0rdami {
  class CsharpCompletion {
    private static int PrivateStaticField1 = default(int);
    string InstanceMethod1(int index) { return default(string); }

    void M0zpstti30f4 (int count) {
       var foo = "this is a string";
       var fred = new System.Collections.Generic.List<String> { foo, foo.Length.ToString() };
       var z = fred.Count;
       var mmm = count + z + CsharpCompletion.PrivateStaticField1;
       var nnn = this.InstanceMethod1(mmm);
      }
  }
}

Todos los miembros de tipo instancia y estático están disponibles en el código esqueleto. Se compila con éxito. En ese punto, determinar el tipo de var local es sencillo a través de Reflection.

Lo que hace esto posible es:

  • la capacidad de ejecutar powershell en emacs
  • el compilador de C # es realmente rápido. En mi máquina, se necesitan aproximadamente 0,5 segundos para compilar un ensamblado en memoria. No lo suficientemente rápido para el análisis entre pulsaciones de teclas, pero lo suficientemente rápido como para admitir la generación bajo demanda de listas de finalización.

Todavía no he investigado LINQ.
Eso será un problema mucho mayor porque el lexer / analizador semántico emacs tiene para C #, no "hace" LINQ.

Cheeso
fuente
4
El compilador determina y completa el tipo de foo mediante la inferencia de tipos. Sospecho que los mecanismos son completamente diferentes. ¿Quizás el motor de inferencia de tipos tiene un gancho? Como mínimo, usaría 'type-inference' como etiqueta.
George Mauer
3
Su técnica para hacer un modelo de objeto "falso" que tenga todos los tipos pero ninguna semántica de los objetos reales es buena. Así es como hice IntelliSense para JScript en Visual InterDev en el pasado; hacemos una versión "falsa" del modelo de objetos de IE que tiene todos los métodos y tipos pero ninguno de los efectos secundarios, y luego ejecutamos un pequeño intérprete sobre el código analizado en tiempo de compilación y vemos qué tipo regresa.
Eric Lippert

Respuestas:

202

Puedo describirle cómo lo hacemos de manera eficiente en el IDE de C # "real".

Lo primero que hacemos es ejecutar una pasada que analiza solo las cosas de "nivel superior" en el código fuente. Omitimos todos los cuerpos del método. Eso nos permite crear rápidamente una base de datos de información sobre qué espacios de nombres, tipos y métodos (y constructores, etc.) se encuentran en el código fuente del programa. Analizar cada línea de código en cada cuerpo de método llevaría demasiado tiempo si intenta hacerlo entre pulsaciones de tecla.

Cuando el IDE necesita determinar el tipo de una expresión en particular dentro del cuerpo de un método, digamos que ha escrito "foo". y tenemos que averiguar quiénes son los miembros de foo; hacemos lo mismo; nos saltamos todo el trabajo que razonablemente podamos.

Comenzamos con un pase que analiza solo las declaraciones de variables locales dentro de ese método. Cuando ejecutamos esa pasada, hacemos un mapeo de un par de "alcance" y "nombre" a un "determinante de tipo". El "determinante de tipo" es un objeto que representa la noción de "Puedo calcular el tipo de este local si lo necesito". Determinar el tipo de local puede ser costoso, por lo que queremos aplazar ese trabajo si es necesario.

Ahora tenemos una base de datos construida de forma perezosa que puede decirnos el tipo de cada local. Entonces, volviendo a ese "foo". - averiguamos en qué enunciado se encuentra la expresión relevante y luego ejecutamos el analizador semántico solo contra ese enunciado. Por ejemplo, suponga que tiene el cuerpo del método:

String x = "hello";
var y = x.ToCharArray();
var z = from foo in y where foo.

y ahora tenemos que averiguar que foo es de tipo char. Creamos una base de datos que tiene todos los metadatos, métodos de extensión, tipos de código fuente, etc. Creamos una base de datos que tiene determinantes de tipo para x, y y z. Analizamos el enunciado que contiene la expresión interesante. Empezamos transformándolo sintácticamente a

var z = y.Where(foo=>foo.

Para determinar el tipo de foo, primero debemos conocer el tipo de y. Entonces, en este punto, le preguntamos al determinante de tipo "¿cuál es el tipo de y"? Luego inicia un evaluador de expresiones que analiza x.ToCharArray () y pregunta "¿cuál es el tipo de x"? Tenemos un determinante de tipo para eso que dice "Necesito buscar" Cadena "en el contexto actual". No hay ningún tipo String en el tipo actual, por lo que buscamos en el espacio de nombres. Tampoco está allí, así que buscamos en las directivas using y descubrimos que hay un "using System" y que System tiene un tipo String. Bien, ese es el tipo de x.

Luego consultamos los metadatos de System.String para el tipo de ToCharArray y dice que es un System.Char []. Súper. Entonces tenemos un tipo para y.

Ahora preguntamos "¿System.Char [] tiene un método Dónde?" No. Entonces miramos en las directivas using; ya hemos calculado previamente una base de datos que contiene todos los metadatos de los métodos de extensión que podrían utilizarse.

Ahora decimos "OK, hay dieciocho docenas de métodos de extensión llamados Where in scope, ¿alguno de ellos tiene un primer parámetro formal cuyo tipo es compatible con System.Char []?" Entonces comenzamos una ronda de pruebas de convertibilidad. Sin embargo, los métodos de extensión Where son genéricos , lo que significa que tenemos que hacer una inferencia de tipos.

Escribí un motor de inferencia de tipo especial que puede manejar la realización de inferencias incompletas desde el primer argumento hasta un método de extensión. Ejecutamos el inferidor de tipos y descubrimos que hay un método Where que toma an IEnumerable<T>, y que podemos hacer una inferencia desde System.Char [] a IEnumerable<System.Char>, por lo que T es System.Char.

La firma de este método es Where<T>(this IEnumerable<T> items, Func<T, bool> predicate), y sabemos que T es System.Char. También sabemos que el primer argumento dentro del paréntesis del método de extensión es una lambda. Así que iniciamos un inferidor de tipo de expresión lambda que dice "se supone que el parámetro formal foo es System.Char", use este hecho cuando analice el resto de lambda.

Ahora tenemos toda la información que necesitamos para analizar el cuerpo de la lambda, que es "foo". Buscamos el tipo de foo, descubrimos que según el enlazador lambda es System.Char, y hemos terminado; mostramos información de tipo para System.Char.

Y hacemos todo excepto el análisis de "nivel superior" entre pulsaciones de teclas . Esa es la parte realmente complicada. En realidad, escribir todo el análisis no es difícil; lo hace lo suficientemente rápido como para que pueda hacerlo a la velocidad de escritura, lo que es realmente complicado.

¡Buena suerte!

Eric Lippert
fuente
8
Eric, gracias por la respuesta completa. Me has abierto un poco los ojos. Para emacs, no aspiraba a producir un motor dinámico entre pulsaciones de teclas que pudiera competir con Visual Studio en términos de calidad de la experiencia del usuario. Por un lado, debido a la latencia de ~ 0.5s inherente a mi diseño, la función basada en emacs es y seguirá siendo solo bajo demanda; sin sugerencias de escritura anticipada. Por otro lado, implementaré el soporte básico de var locales, pero felizmente apuntaré cuando las cosas se pongan complicadas o cuando el gráfico de dependencia exceda un cierto límite. No estoy seguro de cuál es ese límite todavía. Gracias de nuevo.
Cheeso
13
Honestamente, me sorprende que todo esto pueda funcionar de manera tan rápida y confiable, particularmente con expresiones lambda e inferencia de tipos genéricos. De hecho, me sorprendió bastante la primera vez que escribí una expresión lambda e Intellisense sabía el tipo de mi parámetro cuando presioné., Aunque la declaración aún no estaba completa y nunca especifiqué explícitamente los parámetros genéricos de los métodos de extensión. Gracias por este pequeño vistazo a la magia.
Dan Bryant
21
@Dan: He visto (o escrito) el código fuente y me sorprende que también funcione. :-) Hay cosas peludas ahí.
Eric Lippert
11
Los chicos de Eclipse probablemente lo hagan mejor porque son más increíbles que el compilador de C # y el equipo IDE.
Eric Lippert
23
No recuerdo haber hecho este comentario estúpido en absoluto. Ni siquiera tiene sentido. Debo haber estado borracho. Lo siento.
Tomas Andrle
15

Puedo decirles aproximadamente cómo funciona el IDE de Delphi con el compilador de Delphi para hacer intellisense (la información del código es lo que Delphi lo llama). No es 100% aplicable a C #, pero es un enfoque interesante que merece consideración.

La mayor parte del análisis semántico en Delphi se realiza en el propio analizador. Las expresiones se escriben a medida que se analizan, excepto en situaciones en las que esto no es fácil, en cuyo caso se usa el análisis anticipado para determinar lo que se pretende, y luego esa decisión se usa en el análisis.

El análisis es en gran parte descenso recursivo LL (2), excepto para las expresiones, que se analizan utilizando la precedencia del operador. Una de las cosas distintivas de Delphi es que es un lenguaje de un solo paso, por lo que las construcciones deben declararse antes de usarse, por lo que no se necesita un pase de nivel superior para sacar esa información.

Esta combinación de funciones significa que el analizador tiene aproximadamente toda la información necesaria para obtener información sobre el código en cualquier punto donde se necesite. La forma en que funciona es la siguiente: el IDE informa al lexer del compilador de la posición del cursor (el punto donde se desea obtener información sobre el código) y el lexer lo convierte en un token especial (se llama token kibitz). Siempre que el analizador se encuentra con este token (que podría estar en cualquier lugar), sabe que esta es la señal para devolver toda la información que tiene al editor. Hace esto usando un longjmp porque está escrito en C; lo que hace es notificar al llamador final sobre el tipo de construcción sintáctica (es decir, contexto gramatical) en el que se encontró el punto kibitz, así como todas las tablas simbólicas necesarias para ese punto. Así por ejemplo, Si el contexto está en una expresión que es un argumento para un método, podemos verificar las sobrecargas del método, mirar los tipos de argumentos y filtrar los símbolos válidos solo para aquellos que pueden resolver ese tipo de argumento (esto reduce en un mucho cruft irrelevante en el menú desplegable). Si está en un contexto de alcance anidado (por ejemplo, después de un "."), El analizador habrá devuelto una referencia al alcance y el IDE puede enumerar todos los símbolos que se encuentran en ese alcance.

También se hacen otras cosas; por ejemplo, los cuerpos de los métodos se omiten si el token kibitz no se encuentra en su rango; esto se hace de manera optimista y se revierte si se salta el token. El equivalente de los métodos de extensión, los ayudantes de clases en Delphi, tienen una especie de caché versionado, por lo que su búsqueda es razonablemente rápida. Pero la inferencia de tipos genéricos de Delphi es mucho más débil que la de C #.

Ahora, a la pregunta específica: inferir los tipos de variables declaradas con vares equivalente a la forma en que Pascal infiere el tipo de constantes. Viene del tipo de expresión de inicialización. Estos tipos se construyen de abajo hacia arriba. Si xes de tipo Integer, y yes de tipo Double, entonces x + yserá de tipo Double, porque esas son las reglas del lenguaje; etc. Siga estas reglas hasta que tenga un tipo para la expresión completa en el lado derecho, y ese es el tipo que usa para el símbolo de la izquierda.

Barry Kelly
fuente
7

Si no quiere tener que escribir su propio analizador para construir el árbol de sintaxis abstracta, puede utilizar los analizadores de SharpDevelop o MonoDevelop , ambos de código abierto.

Daniel Plaisted
fuente
4

Los sistemas Intellisense típicamente representan el código usando un árbol de sintaxis abstracta, lo que les permite resolver el tipo de retorno de la función que se asigna a la variable 'var' más o menos de la misma manera que lo hará el compilador. Si usa VS Intellisense, puede notar que no le dará el tipo de var hasta que haya terminado de ingresar una expresión de asignación válida (que se pueda resolver). Si la expresión sigue siendo ambigua (por ejemplo, no puede inferir completamente los argumentos genéricos de la expresión), el tipo var no se resolverá. Este puede ser un proceso bastante complejo, ya que es posible que deba caminar bastante profundo en un árbol para resolver el tipo. Por ejemplo:

var items = myList.OfType<Foo>().Select(foo => foo.Bar);

El tipo de retorno es IEnumerable<Bar>, pero para resolver esto se requiere saber:

  1. myList es del tipo que implementa IEnumerable.
  2. Existe un método de extensión OfType<T>que se aplica a IEnumerable.
  3. El valor resultante es IEnumerable<Foo>y hay un método de extensión Selectque se aplica a esto.
  4. La expresión lambda foo => foo.Bartiene el parámetro foo de tipo Foo. Esto se infiere mediante el uso de Select, que toma un Func<TIn,TOut>y, dado que se conoce TIn (Foo), se puede inferir el tipo de foo.
  5. El tipo Foo tiene una propiedad Bar, que es de tipo Bar. Sabemos que Select devuelve IEnumerable<TOut>y TOut se puede inferir del resultado de la expresión lambda, por lo que el tipo de elementos resultante debe ser IEnumerable<Bar>.
Dan Bryant
fuente
Bien, puede ser bastante profundo. Me siento cómodo resolviendo todas las dependencias. Solo pensando en esto, la primera opción que describí, compilar e invocar, es absolutamente inaceptable, porque invocar código puede tener efectos secundarios, como actualizar una base de datos, y eso no es algo que un editor debería hacer. La compilación está bien, la invocación no. En cuanto a la construcción del AST, no creo que quiera hacer eso. Realmente quiero dejar ese trabajo al compilador, que ya sabe cómo hacerlo. Quiero poder pedirle al compilador que me diga lo que quiero saber. Solo quiero una respuesta simple.
Cheeso
El desafío de inspeccionarlo desde la compilación es que las dependencias pueden ser arbitrariamente profundas, lo que significa que es posible que deba compilar todo para que el compilador genere código. Si hace eso, creo que puede usar los símbolos del depurador con el IL generado y hacer coincidir el tipo de cada local con su símbolo.
Dan Bryant
1
@Cheeso: el compilador no ofrece ese tipo de análisis de tipo como servicio. Espero que en el futuro lo sea, pero no hay promesas.
Eric Lippert
sí, creo que ese podría ser el camino a seguir: resolver todas las dependencias y luego compilar e inspeccionar IL. @Eric, bueno saberlo. Por ahora, si no aspiro a hacer el análisis AST completo, debo recurrir a un truco sucio para producir este servicio utilizando las herramientas existentes. Por ejemplo, compile un fragmento de código construido inteligentemente y luego use ILDASM (o similar) programáticamente para obtener la respuesta que busco.
Cheeso
4

Dado que está apuntando a Emacs, puede ser mejor comenzar con la suite CEDET. Todos los detalles de Eric Lippert están cubiertos en el analizador de código en la herramienta CEDET / Semantic para C ++ ya. También hay un analizador de C # (que probablemente necesite un poco de TLC), por lo que las únicas partes que faltan están relacionadas con el ajuste de las partes necesarias para C #.

Los comportamientos básicos se definen en algoritmos centrales que dependen de funciones sobrecargables que se definen por idioma. El éxito del motor de finalización depende de la cantidad de ajustes que se hayan realizado. Con c ++ como guía, obtener soporte similar a C ++ no debería ser tan malo.

La respuesta de Daniel sugiere usar MonoDevelop para realizar análisis y análisis. Este podría ser un mecanismo alternativo en lugar del analizador de C # existente, o podría usarse para aumentar el analizador existente.

Eric
fuente
Bien, conozco CEDET y estoy usando el soporte de C # en el directorio contrib para semántica. Semantic proporciona la lista de variables locales y sus tipos. Un motor de finalización puede escanear esa lista y ofrecer las opciones correctas al usuario. El problema es cuando la variable es var. Semantic lo identifica correctamente como var, pero no proporciona inferencia de tipo. Mi pregunta giraba específicamente en torno a cómo abordar eso . También busqué conectarme a la terminación de CEDET existente, pero no pude averiguar cómo. La documentación de CEDET está ... ah ... no está completa.
Cheeso
Comentario lateral: CEDET es admirablemente ambicioso, pero me resultó difícil de usar y ampliar. Actualmente, el analizador trata el "espacio de nombres" como un indicador de clase en C #. Ni siquiera pude averiguar cómo agregar "espacio de nombres" como un elemento sintáctico distinto. Hacerlo evitó todos los demás análisis sintácticos y no pude entender por qué. Anteriormente expliqué la dificultad que tenía con el marco de finalización. Más allá de estos problemas, existen costuras y superposiciones entre las piezas. Como ejemplo, la navegación es parte tanto de semántica como de senador. CEDET parece atractivo, pero al final ... es demasiado difícil de manejar para comprometerse.
Cheeso
Cheeso, si desea aprovechar al máximo las partes menos documentadas de CEDET, su mejor opción es probar la lista de correo. Es fácil para las preguntas profundizar en áreas que aún no se han desarrollado bien, por lo que se necesitan algunas iteraciones para encontrar buenas soluciones o explicar las existentes. Para C # en particular, dado que no sé nada al respecto, no habrá respuestas simples únicas.
Eric
2

Es un problema difícil hacerlo bien. Básicamente, necesita modelar la especificación / compilador del lenguaje a través de la mayor parte del lexing / parsing / typechecking y construir un modelo interno del código fuente que luego puede consultar. Eric lo describe en detalle para C #. Siempre puede descargar el código fuente del compilador de F # (parte del CTP de F #) y echarle un vistazo service.fsipara ver la interfaz expuesta del compilador de F # que consume el servicio de lenguaje F # para proporcionar intellisense, información sobre herramientas para tipos inferidos, etc. un sentido de una posible 'interfaz' si ya tenía el compilador disponible como una API para llamar.

La otra ruta es reutilizar los compiladores tal como están como está describiendo y luego usar la reflexión o mirar el código generado. Esto es problemático desde el punto de vista de que necesita 'programas completos' para obtener una salida de compilación de un compilador, mientras que al editar el código fuente en el editor, a menudo solo tiene 'programas parciales' que aún no analizan, no tener implementados todos los métodos, etc.

En resumen, creo que la versión de 'bajo presupuesto' es muy difícil de hacer bien, y la versión 'real' es muy, muy difícil de hacer bien. (Donde 'difícil' aquí mide tanto 'esfuerzo' como 'dificultad técnica').

Brian
fuente
Sí, la versión de "bajo presupuesto" tiene algunas limitaciones claras. Estoy tratando de decidir qué es "suficientemente bueno" y si puedo cumplir con ese listón. En mi propia experiencia, hacer una prueba interna de lo que tengo hasta ahora, hace que escribir C # en emacs sea mucho más agradable.
Cheeso
0

Para la solución "1", tiene una nueva función en .NET 4 para hacer esto rápida y fácilmente. Entonces, si puede convertir su programa a .NET 4, sería su mejor opción.

Softlion
fuente