¿Se derrota el objetivo de una clara componenteización al compartir demasiada información de tipos entre bibliotecas? Tal vez necesite un almacenamiento de datos fuertemente tipado eficiente, pero sería muy costoso si necesita actualizar el esquema de su base de datos cada vez que el modelo de objetos evoluciona, ¿lo haría?
en lugar de inferir su esquema de tipos en tiempo de ejecución? ¿Necesita entregar componentes que acepten objetos de usuario arbitrarios ymanejarlos
de alguna manera inteligente? ¿Quiere que el compilador de la biblioteca pueda decirle mediante programación cuáles son sus tipos?
Para mantener estructuras de datos fuertemente tipadas y al mismo tiempo maximizar la flexibilidad del tiempo de ejecución, probablemente desee considerar la reflexión y cómo puede mejorar su software. En esta columna, exploraré el espacio de nombres System.Reflection en Microsoft .NET Framework y cómo puede beneficiar su experiencia de desarrollo. Comenzaré con algunos ejemplos simples y terminaré con cómo manejar situaciones de serialización del mundo real. A lo largo del camino, mostraré cómo la reflexión y CodeDom trabajan juntos para manejar de manera eficiente los datos en tiempo de ejecución.
Antes de profundizar en System.Reflection, me gustaría hablar sobre la programación reflexiva en general. En primer lugar, la reflexión se puede definir como cualquier funcionalidad proporcionada por un sistema de programación que permite a los programadores inspeccionar y manipular entidades de código sin conocimiento previo de su identidad o estructura formal. Hay mucho que cubrir en esta sección, así que lo abordaré uno por uno.
Primero, ¿qué proporciona la reflexión? ¿Qué se puede hacer con ella? Tiendo a dividir las tareas típicas centradas en la reflexión en dos categorías: inspección y manipulación. La inspección requiere analizar objetos y tipos para recopilar información estructurada sobre su definición y comportamiento. Aparte de algunas disposiciones básicas, esto se hace a menudo sin ningún conocimiento previo de ellas. (Por ejemplo, en .NET Framework, todo hereda de System.Object, y una referencia a un tipo de objeto suele ser el punto de partida general para la reflexión).
Las operaciones invocan código dinámicamente utilizando información recopilada mediante inspección, creación de nuevas instancias o incluso Los tipos y objetos se pueden reestructurar fácilmente de forma dinámica. Un punto importante a destacar es que, para la mayoría de los sistemas, la manipulación de tipos y objetos en tiempo de ejecución da como resultado una degradación del rendimiento en comparación con realizar operaciones equivalentes estáticamente en el código fuente. Esta es una compensación necesaria debido a la naturaleza dinámica de la reflexión, pero existen muchos consejos y mejores prácticas para optimizar el rendimiento de la reflexión (consulte msdn.microsoft.com/msdnmag/issues/05 para obtener información más detallada sobre cómo optimizar el uso de la reflexión /07/Reflexión).
Entonces, ¿cuál es el objetivo de la reflexión? ¿Qué inspecciona y manipula realmente el programador? En mi definición de reflexión, utilicé el nuevo término "entidad de código" para enfatizar el hecho de que, desde la perspectiva del programador, las técnicas de reflexión a veces desdibujan las líneas entre ellas. Objetos y tipos tradicionales. Por ejemplo, una tarea típica centrada en la reflexión podría ser:
comenzar con un identificador para el objeto O y usar la reflexión para obtener un identificador para su definición asociada (tipo T).
Examine el tipo T y obtenga un identificador de su método M.
Llame al método M de otro objeto O' (también de tipo T).
Tenga en cuenta que estoy pasando de una instancia a su tipo subyacente, de ese tipo a un método, y luego uso el identificador del método para llamarlo en otra instancia; obviamente, esto es usar la programación tradicional de C# en el código fuente. La tecnología no puede lograrlo. Después de analizar System.Reflection de .NET Framework a continuación, explicaré esta situación nuevamente con un ejemplo concreto.
Algunos lenguajes de programación proporcionan reflexión de forma nativa a través de la sintaxis, mientras que otras plataformas y marcos (como .NET Framework) la proporcionan como una biblioteca del sistema. Independientemente de cómo se proporcione la reflexión, las posibilidades de utilizar la tecnología de reflexión en una situación determinada son bastante complejas. La capacidad de un sistema de programación para proporcionar reflexión depende de muchos factores: ¿El programador hace un buen uso de las características del lenguaje de programación para expresar sus conceptos? ¿Incorpora el compilador suficiente información estructurada (metadatos) en la salida para facilitar el análisis futuro? ¿Interpretación? ¿Existe un subsistema de tiempo de ejecución o un intérprete de host que digiera estos metadatos? ¿La biblioteca de la plataforma presenta los resultados de esta interpretación de una manera que sea útil para los programadores
si tiene en mente un sistema de tipos complejo y orientado a objetos
?aparece como una función simple de estilo C en el código y no existe una estructura de datos formal, entonces es obviamente imposible que su programa infiera dinámicamente que el puntero de una determinada variable v1 apunta a una instancia de objeto de cierto tipo T . Porque después de todo, el tipo T es un concepto en tu cabeza y nunca aparece explícitamente en tus declaraciones de programación; Pero si usa un lenguaje orientado a objetos más flexible (como C#) para expresar la estructura abstracta del programa e introduce directamente el concepto de tipo T, entonces el compilador convertirá su idea en algo que luego podrá pasar a través del Lógica adecuada para comprender el formulario, proporcionada por Common Language Runtime (CLR) o algún intérprete de lenguaje dinámico.
¿Es la reflexión una tecnología de tiempo de ejecución completamente dinámica? En pocas palabras, no lo es. Hay muchos momentos a lo largo del ciclo de desarrollo y ejecución en los que la reflexión está disponible y es útil para los desarrolladores. Algunos lenguajes de programación se implementan mediante compiladores independientes que convierten el código de alto nivel directamente en instrucciones que la máquina puede entender. El archivo de salida solo incluye entradas compiladas y el tiempo de ejecución no tiene ninguna lógica de soporte para aceptar objetos opacos y analizar dinámicamente sus definiciones. Este es exactamente el caso de muchos compiladores de C tradicionales. Debido a que hay poca lógica de soporte en el ejecutable de destino, no se puede hacer mucha reflexión dinámica, pero los compiladores sí proporcionan reflexión estática de vez en cuando; por ejemplo, el omnipresente operador typeof permite a los programadores verificar los identificadores de tipo en el momento de la compilación.
Una situación completamente diferente es que los lenguajes de programación interpretados siempre se ejecutan a través del proceso principal (los lenguajes de scripting generalmente entran en esta categoría). Dado que la definición completa del programa está disponible (como el código fuente de entrada), combinada con la implementación completa del lenguaje (como el intérprete mismo), todas las técnicas necesarias para respaldar el autoanálisis están implementadas. Este lenguaje dinámico frecuentemente proporciona capacidades de reflexión integrales, así como un rico conjunto de herramientas para el análisis dinámico y la manipulación de programas.
.NET Framework CLR y sus lenguajes host como C# están en el medio. El compilador se utiliza para convertir el código fuente en IL y metadatos. Este último es de nivel inferior o menos "lógico" que el código fuente, pero aún conserva mucha estructura abstracta e información de tipo. Una vez que CLR inicia y aloja este programa, la biblioteca System.Reflection de la biblioteca de clases base (BCL) puede usar esta información y devolver información sobre el tipo de objeto, los miembros del tipo, las firmas de los miembros, etc. Además, también puede admitir llamadas, incluidas las de enlace tardío.
Reflexión en .NET
Para aprovechar la reflexión al programar con .NET Framework, puede utilizar el espacio de nombres System.Reflection. Este espacio de nombres proporciona clases que encapsulan muchos conceptos de tiempo de ejecución, como ensamblados, módulos, tipos, métodos, constructores, campos y propiedades. La tabla de la Figura 1 muestra cómo las clases en System.Reflection se asignan a sus contrapartes de tiempo de ejecución conceptual.
Aunque son importantes, System.Reflection.Assembly y System.Reflection.Module se utilizan principalmente para localizar y cargar código nuevo en el tiempo de ejecución. En esta columna, no discutiré estas partes y asumiré que ya se ha cargado todo el código relevante.
Para inspeccionar y manipular el código cargado, el patrón típico es principalmente System.Type. Normalmente, se empieza obteniendo una instancia System.Type de la clase de tiempo de ejecución de interés (a través de Object.GetType). Luego puede utilizar varios métodos de System.Type para explorar la definición del tipo en System.Reflection y obtener instancias de otras clases. Por ejemplo, si está interesado en un método específico y desea obtener una instancia System.Reflection.MethodInfo de este método (quizás a través de Type.GetMethod). Del mismo modo, si está interesado en un campo y desea obtener una instancia System.Reflection.FieldInfo de este campo (quizás a través de Type.GetField).
Una vez que tenga todos los objetos de instancia de reflexión necesarios, puede continuar siguiendo los pasos de inspección o manipulación según sea necesario. Al realizar la verificación, utiliza varias propiedades descriptivas en la clase reflectante para obtener la información que necesita (¿Es este un tipo genérico? ¿Es este un método de instancia?). Al operar, puede llamar y ejecutar métodos dinámicamente, crear nuevos objetos llamando a constructores, etc.
Verificación de tipos y miembros
Saltemos a un poco de código y exploremos cómo verificar usando la reflexión básica. Me centraré en el análisis de tipos. Comenzando con un objeto, recuperaré su tipo y luego examinaré algunos miembros interesantes (ver Figura 2).
Lo primero que hay que tener en cuenta es que en la definición de clase, a primera vista parece que hay mucho más espacio para describir los métodos de lo que esperaba. ¿De dónde provienen estos métodos adicionales? Cualquier persona versada en la jerarquía de objetos de .NET Framework reconocerá estos métodos heredados de la clase base común Object. (De hecho, utilicé por primera vez Object.GetType para recuperar su tipo). Además, puede ver la función getter para la propiedad. Ahora, ¿qué pasa si solo necesita las funciones definidas explícitamente de MyClass? En otras palabras, ¿cómo oculta las funciones heredadas? ¿O tal vez solo necesita las funciones de instancia definidas explícitamente?
Simplemente eche un vistazo en línea a MSDN y lo sabrá. Descubrí que todos están dispuestos a utilizar el segundo método sobrecargado de GetMethods, que acepta el parámetro BindingFlags. Al combinar diferentes valores de la enumeración BindingFlags, puede hacer que una función devuelva solo el subconjunto de métodos deseado. Reemplace la llamada GetMethods con:
GetMethods(BindingFlags.Instance | BindingFlags.DeclaredOnly |BindingFlags.Public)
Como resultado, obtendrá el siguiente resultado (tenga en cuenta que no hay funciones auxiliares estáticas ni funciones heredadas de System.Object).
Ejemplo de demostración de reflexión 1
Nombre del tipo: MyClass
Nombre del método: MyMethod1
Nombre del método: MyMethod2
Nombre del método: get_MyProperty
Nombre de la propiedad: MyProperty
¿Qué pasa si conoce el nombre del tipo (completamente calificado) y los miembros de antemano? ¿Cómo se logra recuperar de un tipo de enumeración a Type? ¿Conversión? Con el código de los dos primeros ejemplos, ya tienes los componentes básicos para implementar un navegador de clase primitivo. Puede buscar una entidad en tiempo de ejecución por su nombre y luego enumerar sus diversas propiedades relacionadas.
Llamar código dinámicamente
Hasta ahora he obtenido identificadores de objetos en tiempo de ejecución (como tipos y métodos) sólo con fines descriptivos, como imprimir sus nombres. Pero, ¿cómo se puede hacer más? ¿Cómo se llama realmente a un método?
Algunos puntos clave en este ejemplo son: primero, se recupera una instancia de System.Type de una instancia de MyClass, mc1, y luego, se recupera una instancia de MethodInfo. ese tipo. Finalmente, cuando se llama a MethodInfo, se vincula a otra instancia de MyClass (mc2) pasándola como primer parámetro de la llamada.
Como se mencionó anteriormente, este ejemplo desdibuja la distinción entre tipos y uso de objetos que esperaría ver en el código fuente. Lógicamente, recuperas un identificador de un método y luego llamas al método como si perteneciera a un objeto diferente. Para los programadores que están familiarizados con los lenguajes de programación funcionales, esto puede ser muy sencillo, pero para los programadores que solo están familiarizados con C#, puede que no sea tan intuitivo separar la implementación de objetos y la creación de instancias de objetos.
Juntándolo todo
Hasta ahora he discutido los principios básicos de verificar e igualar, y ahora los reuniré con ejemplos concretos. Imagine que desea entregar una biblioteca con funciones auxiliares estáticas que deben manejar objetos. Pero en el momento del diseño, no tienes idea de los tipos de estos objetos. Depende de las instrucciones del autor de la función sobre cómo quiere extraer información significativa de estos objetos. La función aceptará una colección de objetos y una cadena descriptiva del método. Luego recorrerá la colección, llamando a los métodos de cada objeto y agregando los valores de retorno con alguna función.
Para este ejemplo, declararé algunas restricciones. Primero, el método descrito por el parámetro de cadena (que debe ser implementado por el tipo subyacente de cada objeto) no aceptará ningún parámetro y devolverá un número entero. El código recorrerá la colección de objetos, llamará al método especificado y calculará gradualmente el promedio de todos los valores. Finalmente, dado que este no es un código de producción, no tengo que preocuparme por la validación de parámetros o el desbordamiento de enteros al sumar.
Al explorar el código de muestra, puede ver que la concordancia entre la función principal y el asistente estático ComputeAverage no se basa en ningún tipo de información que no sea la clase base común del objeto en sí. En otras palabras, puede cambiar completamente el tipo y la estructura del objeto que se transfiere, pero siempre que pueda usar una cadena para describir un método que devuelve un número entero, ComputeAverage funcionará bien. ¡
Una cuestión clave a tener en cuenta es que
!está oculto en El último ejemplo está relacionado con MethodInfo (reflexión general). Tenga en cuenta que en el bucle foreach de ComputeAverage, el código solo toma MethodInfo del primer objeto de la colección y luego lo vincula a la llamada de todos los objetos posteriores. Como muestra la codificación, funciona bien: este es un ejemplo simple de almacenamiento en caché de MethodInfo. Pero aquí hay una limitación fundamental. Una instancia de MethodInfo solo puede ser llamada por una instancia del mismo tipo jerárquico que el objeto que recupera. Esto es posible porque se pasan instancias de IntReturner y SonOfIntReturner (heredadas de IntReturner).
En el código de muestra, se incluyó una clase llamada EnemyOfIntReturner, que implementa el mismo protocolo básico que las otras dos clases, pero no comparte ningún tipo común. En otras palabras, las interfaces son lógicamente equivalentes, pero no hay superposición a nivel de tipo. Para explorar el uso de MethodInfo en esta situación, intente agregar otro objeto a la colección, obtenga una instancia a través de "new EnemyOfIntReturner(10)" y ejecute el ejemplo nuevamente. Encontrará una excepción que indica que MethodInfo no se puede utilizar para llamar al objeto especificado porque no tiene absolutamente nada que ver con el tipo original del que se obtuvo MethodInfo (aunque el nombre del método y el protocolo subyacente son equivalentes). Para que su código esté listo para producción, debe estar preparado para enfrentar esta situación.
Una posible solución podría ser analizar usted mismo los tipos de todos los objetos entrantes, conservando la interpretación de su jerarquía de tipos compartida (si corresponde). Si el tipo del siguiente objeto es diferente de cualquier jerarquía de tipos conocida, es necesario obtener y almacenar una nueva MethodInfo. Otra solución es detectar TargetException y volver a obtener una instancia de MethodInfo. Ambas soluciones mencionadas aquí tienen sus pros y sus contras. Joel Pobar escribió un excelente artículo para la edición de mayo de 2007 de esta revista sobre el almacenamiento en búfer y el rendimiento de reflexión de MethodInfo, que recomiendo ampliamente.
Con suerte, este ejemplo demuestra cómo agregar reflexión a una aplicación o marco para agregar más flexibilidad para futuras personalizaciones o extensibilidad. Es cierto que utilizar la reflexión puede resultar un poco engorroso en comparación con la lógica equivalente en lenguajes de programación nativos. Si cree que agregar un enlace tardío basado en reflexión a su código es demasiado engorroso para usted o sus clientes (después de todo, necesitan que sus tipos y códigos se tengan en cuenta en su marco de alguna manera), entonces puede que solo sea necesario con moderación y flexibilidad. para lograr cierto equilibrio.
Manejo eficiente de tipos para serialización
Ahora que hemos cubierto los principios básicos de la reflexión .NET a través de varios ejemplos, echemos un vistazo a una situación del mundo real. Si su software interactúa con otros sistemas a través de servicios web u otras tecnologías remotas fuera de proceso, es probable que haya encontrado problemas de serialización. La serialización esencialmente convierte objetos activos que ocupan memoria en un formato de datos adecuado para la transmisión en línea o el almacenamiento en disco.
El espacio de nombres System.Xml.Serialization en .NET Framework proporciona un potente motor de serialización con XmlSerializer, que puede tomar cualquier objeto administrado y convertirlo a XML (los datos XML también se pueden convertir nuevamente a una instancia de objeto con tipo en el futuro. Este proceso se llama deserialización). La clase XmlSerializer es un software potente y listo para la empresa que será su primera opción si enfrenta problemas de serialización en su proyecto. Pero con fines educativos, exploremos cómo implementar la serialización (u otras instancias de manejo de tipos de tiempo de ejecución similares).
Considere esto: está entregando un marco que toma instancias de objetos de tipos de usuarios arbitrarios y las convierte en algún formato de datos inteligente. Por ejemplo, supongamos que tiene un objeto residente en memoria de tipo Dirección como se muestra a continuación:
(pseudocódigo)
dirección de clase
{
ID de dirección;
Calle Cuerda, Ciudad;
Estado tipo de estado;
Código PostalTipo Código Postal;
}
¿Cómo generar una representación de datos adecuada para su uso posterior? Quizás una simple representación de texto resuelva este problema:
Dirección: 123
Calle: 1 Microsoft Way
Ciudad: Redmond
Estado: WA
Código postal: 98052
Si se comprenden completamente los datos formales que deben convertirse Si escribe por adelantado (por ejemplo, cuando escriba el código usted mismo), las cosas se vuelven muy simples:
foreach(Dirección a en Lista de direcciones)
{
Console.WriteLine(“Dirección:{0}”, a.ID);
Console.WriteLine(“tCalle:{0}”, a.Calle);
... // etcétera
}
Sin embargo, las cosas pueden volverse realmente interesantes si no sabes de antemano qué tipos de datos encontrarás en tiempo de ejecución. ¿Cómo se escribe un código de marco general como este?
MyFramework.TranslateObject (entrada de objeto, salida MyOutputWriter)
Primero, debe decidir qué tipos de miembros son útiles para la serialización. Las posibilidades incluyen capturar solo miembros de un tipo específico, como tipos de sistemas primitivos, o proporcionar un mecanismo para que los autores de tipos indiquen qué miembros deben serializarse, como usar propiedades personalizadas como marcadores en miembros de tipo). Solo puede capturar miembros de un tipo específico, como tipos de sistemas primitivos, o el autor del tipo puede indicar qué miembros deben serializarse (posiblemente mediante el uso de propiedades personalizadas como marcadores en los miembros del tipo).
Una vez que haya documentado los miembros de la estructura de datos que deben convertirse, lo que debe hacer es escribir la lógica para enumerarlos y recuperarlos de los objetos entrantes. Reflection hace el trabajo pesado aquí, permitiéndole consultar tanto estructuras de datos como valores de datos.
En aras de la simplicidad, diseñemos un motor de conversión liviano que tome un objeto, obtenga todos sus valores de propiedad pública, los convierta en cadenas llamando a ToString directamente y luego serialice los valores. Para un objeto determinado llamado "entrada", el algoritmo es aproximadamente el siguiente:
llame a input.GetType para recuperar una instancia System.Type, que describe la estructura subyacente de la entrada.
Utilice Type.GetProperties y el parámetro BindingFlags apropiado para recuperar propiedades públicas como instancias de PropertyInfo.
Las propiedades se recuperan como pares clave-valor mediante PropertyInfo.Name y PropertyInfo.GetValue.
Llame a Object.ToString en cada valor para convertirlo (de forma básica) al formato de cadena.
Empaquete el nombre del tipo de objeto y la colección de nombres de propiedades y valores de cadena en el formato de serialización correcto.
Este algoritmo simplifica significativamente las cosas y al mismo tiempo capta el objetivo de tomar una estructura de datos en tiempo de ejecución y convertirla en datos autodescriptivos. Pero hay un problema: el rendimiento. Como se mencionó anteriormente, la reflexión es muy costosa tanto para el procesamiento de tipos como para la recuperación de valores. En este ejemplo, realizo un análisis de tipo completo en cada instancia del tipo proporcionado.
¿Qué pasaría si fuera posible de alguna manera capturar o preservar su comprensión de la estructura de un tipo para poder recuperarla más tarde sin esfuerzo y manejar eficientemente nuevas instancias de ese tipo? En otras palabras, pasar al paso 3 del algoritmo de ejemplo. La novedad es que es posible hacer esto utilizando funciones de .NET Framework. Una vez que comprenda la estructura de datos de un tipo, puede usar CodeDom para generar dinámicamente código que se vincule a esa estructura de datos. Puede generar un ensamblado auxiliar que contenga una clase auxiliar y métodos que hagan referencia al tipo entrante y accedan a sus propiedades directamente (como cualquier otra propiedad en el código administrado), de modo que la verificación de tipos solo afecte el rendimiento una vez.
Ahora arreglaré este algoritmo. Nuevo tipo:
obtenga la instancia System.Type correspondiente a este tipo.
Utilice los distintos descriptores de acceso System.Type para recuperar el esquema (o al menos el subconjunto del esquema útil para la serialización), como nombres de propiedades, nombres de campos, etc.
Utilice la información del esquema para generar un ensamblaje auxiliar (a través de CodeDom) que se vincule con el nuevo tipo y maneje las instancias de manera eficiente.
Utilice código en un ensamblado auxiliar para extraer datos de instancia.
Serializar datos según sea necesario.
Para todos los datos entrantes de un tipo determinado, puede pasar al paso 4 y obtener una gran mejora en el rendimiento con respecto a la verificación explícita de cada instancia.
Desarrollé una biblioteca de serialización básica llamada SimpleSerialization que implementa este algoritmo usando reflexión y CodeDom (descargable en esta columna). El componente principal es una clase llamada SimpleSerializer, que el usuario construye con una instancia de System.Type. En el constructor, la nueva instancia de SimpleSerializer analiza el tipo dado y genera un ensamblado temporal utilizando clases auxiliares. La clase auxiliar está estrechamente vinculada al tipo de datos dado y maneja la instancia como si estuviera escribiendo el código con completo conocimiento previo del tipo.
La clase SimpleSerializer tiene el siguiente diseño:
clase SimpleSerializer
{
clase pública SimpleSerializer (Tipo tipo de datos);
public void Serialize (entrada de objeto, escritor SimpleDataWriter);
}
¡Simplemente asombroso! El constructor hace el trabajo pesado: usa la reflexión para analizar la estructura de tipos y luego usa CodeDom para generar el ensamblaje auxiliar. La clase SimpleDataWriter es solo un receptor de datos que se utiliza para ilustrar patrones de serialización comunes.
Para
serializar una instancia de clase de dirección simple, utilice el siguiente pseudocódigo para completar la tarea:
SimpleSerializer
mySerializer = new SimpleSerializer(typeof(Address));
SimpleDataWriter escritor = new SimpleDataWriter()
;
Le recomendamos que pruebe el código de muestra usted mismo, especialmente la biblioteca SimpleSerialization. Agregué comentarios a algunas partes interesantes de SimpleSerializer, espero que ayude. Por supuesto, si necesita una serialización estricta en el código de producción, realmente debe confiar en las tecnologías proporcionadas en .NET Framework (como XmlSerializer). Pero si descubre que necesita trabajar con tipos arbitrarios en tiempo de ejecución y manejarlos de manera eficiente, espero que adopte mi biblioteca SimpleSerialization como su solución.