Este artículo es tan solo una somera introducción a la tecnología .NET dando un repaso a sus principales características y explicando de forma breve el funcionamiento del CLR y de los assemblies de .NET de forma que, cuando trabajemos con referencias a ensamblados o bien usemos el System.Reflection tengamos una idea de que estamos haciendo. Buena parte de lo que yo se de este tema viene de dos libros que recomiendo a todo aquel que quiera tener una visión más detallada de .NET (ambos en inglés aunque no se si existen también en español)
Durante este artículo (y de hecho probablemente en cualquier artículo de esta web) utilizo el término ensamblado o assembly para referirme a los Assembly de .NET, Assemly es el nombre ingles y ensamblado la traducción más adecuada española. En general puesto que el término español es básicamente correcto lo utilizaría y dejaría de lado el anglicismo pero, en programación en general y en .NET en particular, es común encontrar referencias a los términos ingleses en prácticamente cualquier lugar de forma que es facil mantener una conversación en la que se hable de GC, GAC y assemblies en vez de RB (por recolector de basura) y CEG (por cache de ensamblados global).
.NET es el nombre que engloba una tecnología creada por Microsoft como un intento (o un paso adelante) de mejorar la tecnología COM.
La técnología COM (Common Object Model), para quién no haya oido hablar de ella, es una tecnología que pretende ser (y en gran parte es) un protocolo de comunicación de aplicaciones (locales o remotas) independiente del lenguaje en que estén definidas dichas aplicaciones. COM introdujo conceptos tales como la separación bien definida de interfaces de implementación así como la definición de acuerdos entre las aplicaciones implicadas en cuanto a la forma de comunicarse. Sin embargo COM presentaba una serie de problemas tanto en cuanto a su definición como a su uso.
Para solucionar los problemas emergentes de .NET (ver el libro de Don Box citado en el prologo) Microsoft comenzó a trabajar en una extensión de COM que evitara dichos problemas y que terminó convirtiendose en el CLR (common language runtime) que constituye la base fundamental de .NET
Hay unos cuantos acrónimos que se repiten bastantes veces cuando lees documentación sobre .NET, CLR (common language runtime), CLI (common language infrastructure), CTS (common type system), CIL (common intermediate language).
En .NET se dice que el código está administrado. Puesto que lo que realmente se ejecuta es código intermedio, dicha ejecución esta vigilada de forma que no pueda realizar acciones inapropiadas.
.NET incorpora un recolector de basura. Esto significa que no deberemos preocuparnos de liberar manualmente la memoria que vayamos dejando de utilizar ya que el recolector de basura (Garbage Collector o GC en inglés) se encarga de monitorizar toda la memoria utilizada, decidir si dicha memoria es accesible de alguna forma y, en caso de no serlo, disponer de ella de la forma más adecuada.
.NET incorpora un sistema llamado CAS (Code Access Security o Seguridad de Acceso a Código) que permite restringir los permisos de ejecución de un determinado código de una forma pormenorizada. Esto significa que podemos definir que un determinado segmento de código (asociado a un determinado rol) tansolo tenga acceso de lectura a tres archivos completos.
Asi mismo podremos también configurar que nuestra librería, que proporciona una serie de servicios proporcione distinta accesibilidad dependiendo del nivel de acceso del programa llamante (por ejemplo podriamos evitar que una aplicación llamante cree nuevos documentos en función de su nivel de acceso, o solo pueda ver determinados documentos).
Si tienes algún princpio esencial en contra de Microsoft eso no debe constituir una barrera. Existen varios desarrollos libres que implementan (o están en proceso de implementar) la especificacion del CIL asi como otras partes de la especificación ECMA lo que permite que ejecuten código intermedio y por tanto assemblies (y ejecutables) generados en dicho lenguaje intermedio. Hasta donde yo conozco hay dos implementaciones principalmente aunque ninguna de las dos está completa (especialmente la nueva funcionalidad del frameworw NET 2.0).
Estos dos proyectos permiten ejecutar proyectos .NET tanto en windows como en Linux (y creo que también en otros sistemas operativos como FreeBSD, MacOSX, etc)
Cuando generamos un ejecutable o una dll .NET en realidad lo que se genera no es código ensamblador (código objeto si hilamos fino) como ocurría si compilabas con Visual Studio 6 o si compilas con Delphi, sino que se genera una estructura especial llamada assembly (ensamblado) que contiene la "traducción" de nuestro código fuente a código intermedio que es la base de .NET.
La cabecera PE (portable executable) es la estructura de los ejecutables (tanto .exe como .dll) de Windows. .NET podriamos decir que extiende o modifica esa cabecera de forma que los ejecutables de .NET puedan ejecutarse de forma totalmente transparente.
En un ejecutable hay diversos secciones (dividas en segmentos), código, de datos y pila. Un ejecutable tradicional contiene la cabecera PE seguida de la sección de código tras las cuales están el resto de secciones en caso de que sean necesarias. En un ejecutable .NET se mantiene la cabecera PE tras la cual hay un pequeño bootstrapper que se encarga de cargar el CLR, concretamente el loader del CLR que a su vez accede a la sección de texto del ejecutable en la cual está ubicada el código intermedio (más concretamente la versión binaria del código intermedio) de la aplicación. De está forma cuando windows ejecuta el archivo el CLR es invocado automáticamente y se hace cargo de la ejecución del código de forma transparente al usuario.
Mono realiza una función parecida pero puesto que la forma de ejecutar con mono es mono.exe nombre_ejecutable sobra la parte de invocar al CLR de forma que directamente accede a la sección de texto donde está ubicado el código intermedio y realiza su ejecución (esto significa, aunque realmente no estoy seguro por que no lo he probado, que un ejecutable generado con el compilador de mono debe ser ejecutado utilizando mono puesto que no contiene el bootstrapper sino tansolo el código intermedio).
Un assembly es la una unidad lógica básica completa de .NET que encapsula uno o más modulos de código. Digo completa por que en realidad puede generarse una estructura asociada a un módulo de código (y que recibe el mismo nombre) y que no podrá ejecutarse ni utilizarse de ninguna forma excepto para ser compilada dentro de un assembly.
Cada assembly contiene una serie de metadatos que definen la información contenida en él. Dichos metadatos incluyen información tal como los assemblies externos que utiliza, las clases, tipos y metodos que están definidos. Un assembly puede englobar a varios modulos de código englobando funcionalidad que, por razones semánticas podemos querer tener en varios archivos dentro de un mismo ejecutable.
La única diferencia (en Windows) entre un .exe y un .dll (en .NET) consiste en que el primero contiene un punto de entrada que debe ser único y que define el lugar en el que comenzará la ejecución así como un bootstrapper que se encarga de cargar el CLR.
Cuando programamos en .NET a veces necesitamos utilizar clases y funcionalidad ubicada en algún assembly externo (de hecho siempre utilizamos como minimo el assembly mscorlib que contiene el System.Object asi como gran parte de las librerías básicas de .NET). Una referencia no es más que la forma de indicar ese uso y de hecho se traducen a CIL como una entrada .assembly extern mscorlib y podremos ver instrucciones como isinst [mscorlib]System.Object.
Como ya he mencionado el código que .NET genera (excepto alguna excepción) es código intermedio. Este código intermedio no es interpretado durante la ejecución sino que es compilado cuando es necesario. Dicha compilación se produce de forma vaga (en ingles lazyness compilation), lo que significa que tansolo se realiza cuando (y si) el código se va a utilizar.
El mecanismo de funcionamiento, a grandes rasgos, consiste en que, durante la primera ejecución la tabla de metodos de las clases contienen referencias a pequeños trozos de código del JIT. Cuando se realiza una llamada al metodo de una clase la dirección de llamada que se obtiene no es realmente el código ensamblador de dicho metodo sino una referencia de activación al JIT que en ese momento realiza la compilación del código intermedio. Una vez compilado modifica la dirección en la tabla de metodos de forma que las llamadas subsiguientes ejecuten directamente el código ya compilado.
Al igual que existe la posibilidad de cargar assemblies de forma estática tenemos la opción de realizar la carga de assemblies de forma dinámica mediante la librería System.Reflection.
Con las dll tradicionales lo único que teníamos era una tabla de direcciones que contenía los offsets de las funciones que la dll exportaba... y eso era todo, no podíamos saber que parametros esperaba la dll lo cual, entre otras cosas significaba que los parametros que pasarmos quedaban codificados como simples direcciones de pila que, posiblemente, podrían ser ejecutadas por la dll o quedar corrompidas o mil cosas más. Además, al no haber propiamente un versionado de dlls podías estar llamando a una versión posterior o anterior cuya especificación no se correspondía en absoluto con lo que uno esperaba. A todo esto terminó conociendoselo como "the dll hell" (el infierno de las dll) debido a los múltiples problemas que ocasinaba.
En primer lugar hay que entender que gracias a la estructura de un assembly, que contiene toda la metainformación sobre lo que este contiene, no solo tenemos la posibilidad (como ocurría con las dll) de invocar determinadas funciones sino que podemos obtener una funcionalidad mucho más amplia.
Al disponer de toda esa información el CLR puede administrar las llamadas al código del assembly de forma que no hagamos cosas extrañas ni "acabemos en medio de memoria incorrecta" (The IT Crowd) ;).
Por otro lado podemos examinar el assembly para descubrir y analizar la funcionalidad que proporciona, buscando por ejemplo clases que implementen un determinado interface, creando instancias dichas clases y tratandolas como si pertenecieran a nuestro propio código.
La librería System.Reflection proporciona diversos metodos estáticos dentro de la clase Assembly que nos permiten abrir un ensamblado. Los más comunes son:
public static Assembly LoadFile (
string path
)
que devuelven una instancia de la clase Assembly referida al assembly que acabamos de cargar y que nos permitirá obtener información tal como obtener todos los tipos definidos en el assembly, crear instancias de objetos definidos, obtener una lista de los metodos y parametros de uno de los tipos que ya hemos obtenido ...
Otra de las cosas que permite .NET es emitir código en tiempo de ejecución, es decir, emitir lineas de código intermedio que serán compiladas por el JIT en tiempo de ejecución y obtendrán código ensamblador que ejecutará como si fuera código fuente que hubieras compilado con el VS.
Para emitir el código fuente .NET nos proporciona varias librerías (a varios niveles). Estás librerías son principalmente System.Reflection.Emit y System.CodeDom. Con System.Reflection.Emit podemos por ejemplo crear un nuevo assembly, asignarle un nombre, definir modulos y tipos, asignar campos a los tipos, definir el constructor, definir metodos ... etc
Aunque el Framework de .NET es muy amplio y complejo, existen todavía algo de funcionalidad de la API que no es alcanzable. Así mismo, en ocasiones necesitamos acceder a funcionalidad que está ubicada en dlls antiguas (lease no .NET) y que no podemos (o no queremos) suplir con las clases del Framework.
P/Invoke define el servicio de invocación de funciones proporcionados por la plataforma, es decir, funcionalidad de librerías pre-CLR.
El atributo DllImport, ubicado en en namespace System.Runtime.InteropServices nos permite realizar dichas invocaciones, por ejemplo
La declaración del metodo se realiza mediante la palabra reservada extern que indica que dicho metodo no se encuentra ubicado en el ensamblado actual. Entre parentesis pasaremos el nombre de la librería que contiene el metodo que deseamos utilizar.
DllImport tiene varios parametros:
En windows la carga de librerías mediante P/Invoke es un proceso que sigue un estandar muy sencillo, cuando se encuentra un [DllImport] se genera el código correspondiente a un LoadLibrary y un GetProcAdress.
En mono la cosa es un poco más complicada. Puesto que mono es multiplataforma, diversas librerías pueden diferir en sus nombres entre una instalación Windows, Linux o MacOSX, asi como pueden diferir su extensión (por ejemplo milib.so.1.2 vs milib.dll). No es el objetivo de este artículo explicar como logra mono realizar de forma transparente el mapeado de los nombres de librería (para más información ver este excelente artículo de Jonathan Pryor sobre P/Invoke) pero baste decir que lo hace de forma que los mismos [DllImport] que funcionan en Windows funcionarán en Linux (incluso en llamadas a librerías del sistema puesto que Mono se encarga en la mayoría de los casos de mapear dicha llamada a otra que realiza la misma función en windows).