Compiladores, intérpretes y máquinas virtuales.

Cuando aprendemos a programar, normalmente escogemos o nos imponen un determinado lenguaje de programación. Una de las primeras distinciones que es interesante conocer acerca de nuestro lenguaje es si el lenguaje se compila o se interpreta.

En este artículo intentaremos explicar las diferencias entre ambas formas de trabajar. Además, desde la aparición del lenguaje Java hace ya algunos años, ha tomado fuerza otro planteamiento muy interesante: el de las llamadas máquinas virtuales.

nota: El término máquina virtual se utiliza en distintos contextos, por ejemplo, productos como VmWare o VirtualBox son software que permiten emular un ordenador, con su sistema operativo y sus aplicaciones dentro de otro. A este tipo de aplicaciones se le suele llamar máquinas virtuales de sistema. En éste artículo nos referimos a las máquinas virtuales de proceso, en las que se simula una máquina imaginaria para ejecutar un único proceso

En este sistema de máquinas virtuales (de proceso) se realiza una compilación y posteriormente una interpretación. No es un concepto nuevo, pero sin duda, no ha tenido una aplicación práctica masiva hasta la llegada de la plataforma Java de Sun Microsystems a principios de los 90 (hoy en día de Oracle), y algo después con la plataforma .NET de Microsoft y su equivalente Mono para entornos unix/Linux. También el sistema operativo Android utiliza este enfoque para la ejecución de sus aplicaciones en terminales con hardware muy distinto.

Antes de comentar cada técnica, hagamos hincapié de una manera muy básica en una serie de conceptos.

La confección de un programa se realiza escribiendo una serie de órdenes o instrucciones que siguen las normas de un lenguaje de programación. Estas órdenes las escribimos en ficheros de texto plano, utilizando algún editor de textos más o menos sencillo, o bien alguno de los editores especializados para programación. Incluso, algunos entornos de programación proporcionan sus propias herramientas específicas para escribir programas, con muchas ventajas para el programador... pero por muy compleja que sea la herramienta para confeccionar programas, por lo general, el programa siempre se reduce a una serie de instrucciones en un fichero de texto.

A estos ficheros de texto les llamamos fuente. (del inglés source. A menudo se utilizan expresiones como código fuente, ficheros fuente, etc.).

Sin embargo, sabemos que la CPU sólo entiende su propio lenguaje, que normalmente es extraordinariamente sencillo comparado el lenguaje de programación que estamos aprendiendo. El lenguaje de la CPU es el código máquina (del inglés machine code). El código máquina son secuencias binarias que la CPU ejecuta como instrucciones sencillas. Por supuesto, cada modelo de CPU tiene su propio código máquina, aunque a veces, varios modelos de CPU tienen lenguajes máquina compatibles.

Los humanos expresamos la dinámica de un programa mediante un lenguaje de los llamados de "alto nivel". Estos son lenguajes como C#, Java, Visual Basic, Delphi, Python, etc... casi cualquiera de los nombres que conoces. Decimos que un lenguaje tiene un nivel más bajo cuanto más parecido es en su expresión al código máquina y al funcionamiento de la CPU, y de un nivel más alto cuanto más ajeno es al funcionamiento de la CPU y más se acerca a la forma de razonar humana.

Pues bien... si realizamos un programa escribiendo su fuente en un lenguaje de alto nivel y la CPU sólo es capaz de ejecutar órdenes en el lenguaje de más bajo nivel que existe (el código máquina), es obvio que es necesario realizar un proceso de traducción desde el lenguaje de alto nivel al código máquina.

Es necesario tener en cuenta que cada orden de un lenguaje de alto nivel (por ejemplo, una orden para imprimir "hola mundo" por la pantalla) se traduce en largas secuencias de instrucciones en código máquina (incluso varios miles). Lo que para nosotros es una sola orden, para la CPU supone muchos pequeños pasos extremadamente simples.

Tampoco podemos olvidarnos del sistema operativo. Los programas de aplicación se apoyan no sólo en la CPU, sino que necesitan de la participación del sistema operativo para realizar muchas de sus labores. Así pues, muchas veces los programas se hacen pensando que su destino va a ser una determinada CPU más un determinado sistema operativo. A menudo, incluso hay que incluir en esta lista otros programas como servidores de aplicaciones, sistemas gestores de bases de datos, librerías de código, etc. A este conjunto de requisitos necesarios para la ejecución de un programa se le suele llamar a menudo plataforma.

Compilar

(NOTA: aquí hablamos de la compilación tradicional, no a la compilación en un entorno de máquina virtual)

La compilación consiste en tomar los ficheros fuente que conforman un programa y, línea por línea, traducir cada instrucción de alto nivel por varias instrucciones en código máquina que realicen lo que la instrucción de alto nivel expresa.

Si se repite esa traducción para todas las líneas del código fuente, obtendremos un conjunto de instrucciones máquina. Grabando esas instrucciones máquina en un fichero que contiene una estructura interna que un determinado sistema operativo es capaz de entender, obtenemos un fichero binario ejecutable, o simplemente, "un ejecutable".

El encargado de realizar esta traducción es un tipo de programa llamado compilador. En su funcionamiento más básico, un compilador acepta uno o más ficheros fuente y si no contienen errores sintácticos produce un fichero binario ejecutable, que un sistema operativo será capaz de cargar en memoria principal y pedir a la CPU que lo ejecute.

A menudo se compara a un compilador con un traductor de idiomas. Imagina un libro escrito en inglés. Para publicar su versión en español, una persona que conoce las reglas de los dos idiomas y dispone de la técnica para expresarse en ambos va leyendo el libro en inglés frase a frase y va escribiendo su equivalente en español.

Compilación

Compilación nativa y compilación cruzada

Es habitual que un desarrollador utilice para programar una determinada plataforma... por ejemplo, el lenguaje C++ sobre una máquina con Windows. Normalmente, el compilador generará código compilado que la CPU puede entender, y que se puede ejecutar en la misma máquina con la participación de un sistema operativo Windows.

No obstante, a veces, se desarrolla en una plataforma y se compila para que los programas sean ejecutados en otra. A esto se le llama "compilación cruzada".

Interpretar

El proceso de interpretación es bastante diferente al de compilación, pero su resultado debería ser similar: la ejecución de un programa.

El encargado de hacer esto es un programa llamado intérprete. A diferencia del compilador, el intérprete de un lenguaje no produce una traducción a código máquina. El intérprete intenta realizar "al vuelo" lo que se expresa en los ficheros fuente. El intérprete contiene en su interior miles de porciones de código máquina, que combinándolas adecuadamente pueden realizar las mismas tareas que expresa una orden escrita en el lenguaje de alto nivel.

Cuando un programa es interpretado, el proceso que se sigue es el siguiente: el intérprete obtiene una instrucción del fichero fuente y la realiza inmediatamente. Para ello, ejecuta en secuencia varias de esas porciones de código máquina que comentábamos antes, y que residen en el interior del intérprete. Cuando la CPU termina la ejecución de esa secuencia, el resultado es que la CPU habrá hecho lo que la línea de código fuente expresaba.

Repitiendo esta secuencia para todas las líneas, el intérprete realiza lo que los ficheros fuente expresan... es decir, ejecuta el programa.

A menudo, se compara al intérprete con un traductor simultáneo de idiomas. Por ejemplo, cuando vemos en la tele a alguna personalidad expresándose en otro idioma y un traductor simultáneo nos traduce lo que esta persona dice "al vuelo". Esta persona no espera a que el discurso de la personalidad concluya para traducirlo todo de golpe, sino que cada vez que este traductor ha entendido un concepto, lo traduce al español y lo expresa.

Diferencias entre compilar e interpretar

La opción de compilar o interpretar no está siempre disponible. Algunos lenguajes típicamente se compilan y otros típicamente se interpretan. En muy pocas ocasiones podemos optar por una u otra indistintamente.

Por ejemplo, programas escritos en lenguajes como C o Pascal prácticamente siempre se compilan, y otros como PHP o Python prácticamente siempre se interpretan.

En el siguiente cuadro se esquematizan algunas de las diferencias entre compilar e interpretar.

Compilar Interpretar
-Genera un ejecutable -No genera un ejecutable
-El proceso de traducción se realiza una sola vez -El proceso de traducción se realiza en cada ejecución
-La ejecución es muy rápida debido a que el programa ya ha sido traducido a código máquina -La ejecución es más lenta, ya que para cada línea del programa es necesario realizar la traducción
-El ejecutable va dirigido a una plataforma concreta (una CPU, un sistema operativo, y quizá alguna otra consideración), siendo prácticamente imposible portarlo a otra. En ocasiones, si existe un compilador para otra plataforma, se puede recompilar el programa, aunque normalmente esto plantea serias dificultades. Los programas que se van a compilar suelen estar muy ligados a la plataforma de destino. -No hay ejecutable, así que si existe un intérprete para una plataforma concreta, el programa se podrá ejecutar en ambas. Típicamente, los programas interpretados son mucho más portables que los compilados, ya que suelen existir intérpretes del mismo lenguaje en distintas plataformas. Los programas que se van a interpretar no suelen ser muy dependientes de su plataforma de destino, siendo más portables.
-Los lenguajes compilados suelen proporcionar al programador mecanismos más potentes y flexibles, a costa de una mayor ligazón a la plataforma. -Los lenguajes interpretados no suelen ser muy dependientes de la plataforma de destino, pero en contrapartida suelen ser menos flexibles y potentes que los compilados.
-Una vez compilado el programa, el código fuente no es necesario para ejecutarlo, así que puede permanecer en secreto si se desea. -El código fuente es necesario en cada ejecución, así que no puede permanecer en secreto
-Los errores sintácticos se detectan durante la compilación. Si el fuente contiene errores sintácticos, el compilador no producirá un ejecutable. -Los errores sintácticos se detectan durante la ejecución, ya que traducción y ejecución se van haciendo simultáneamente. Algún error sintáctico podría quedar enmascarado, si para una ejecución concreta no es necesario traducir la línea que lo contiene. (Algunos intérpretes son capaces de evitar esto)
-Un programa compilado puede, por error, afectar seriamente a la estabilidad de la plataforma, comprometiendo la ejecución de los otros procesos, por ejemplo, acaparando la CPU, la memoria o algún otro recurso, siendo a veces complicado para el sistema operativo interrumpir su ejecución. -Un programa interpretado con un comportamiento torpe normalmente puede ser interrumpido sin dificultad, ya que su ejecución está bajo el control del intérprete, y no sólo del sistema operativo.

El enfoque de Máquina Virtual

Hemos visto que los programas interpretados o compilados tienen distintas ventajas e inconvenientes. En un intento de combinar lo mejor de ambos mundos, durante la década de los 90 surge con fuerza el enfoque de máquina virtual. Los principales lenguajes abanderados de esta tecnología son, por un lado, el lenguaje Java de Oracle, y por otro, los lenguajes de la plataforma .NET de Microsoft: Visual Basic.NET y C# principalmente. También los sistemas Android se basan en este enfoque.

La filosofía de la máquina virtual es la siguiente: el código fuente se compila, detectando los errores sintácticos, y se genera una especie de ejecutable, con un código máquina dirigido a una máquina imaginaria, con una CPU imaginaria. A esta especie de código máquina se le denomina código intermedio, o a veces también lenguaje intermedio, p-code, o byte-code (según quién nos lo cuente).

Como esa máquina imaginaria no existe, para poder ejecutar ese ejecutable, se construye un intérprete. Este intérprete es capaz de leer cada una de las instrucciones de código máquina imaginario y ejecutarlas en la plataforma real. A este intérprete se le denomina el intérprete de la máquina virtual.

Enfoque de máquina virtual

¿Y para qué todo este montaje?

Pues esta pregunta puede responderse desde varios puntos de vista, pero se puede afirmar que este esquema aporta muchas de las ventajas de la compilación y la interpretación, deshaciéndose de algunos inconvenientes.

Principalmente se pueden recalcar:

  • Portabilidad y rapidez: El código intermedio ya está libre de errores sintácticos, y es un código muy sencillo (al estilo del código máquina). Si existe un intérprete para este código en distintas plataformas, el mismo código se puede ejecutar en cada una de ellas. Además, la construcción de este intérprete será relativamente sencilla y su ejecución más rápida, ya que no ha de comprobar la sintaxis.
  • Estabilidad: El código intermedio no es ejecutado por una CPU real directamente, sino por una CPU virtual: realmente, por el intérprete de la máquina virtual, que es un programa y no un chip real. Esto permite un mayor control sobre este código, facilitando la labor de impedir que un código descontrolado afecte a la estabilidad de la plataforma real.

Para entender algo mejor este concepto, podemos fijarnos, por ejemplo, en el lenguaje JAVA. Imaginemos que disponemos de dos ordenadores: uno con un sistema operativo Windows y un procesador intel de 64 bits y el otro con un sistema operativo Linux y un procesador AMD de 32 bits.

  • En el primer ordenador instalamos un compilador de Java y una máquina virtual de Java específicos para Windows 64 bits.
  • En el segundo hacemos lo mismo, pero con un compilador y máquina virtual específicos para Linux 32 bits.
  • Confeccionamos un programa sencillo (por ejemplo, que escriba "Hola Mundo" por la pantalla) escrito en Java en el primer ordenador y lo compilamos, generando un ejecutable intermedio. Si utilizamos la máquina virtual del primer ordenador para ejecutar ese código intermedio, comprobaremos que el programa escribe, en efecto "Hola Mundo" por la pantalla.
  • Si cogemos ese ejecutable intermedio lo llevamos tal cual a la segunda máquina, podremos utilizar la máquina virtual instalada allí para ejecutarlo, y comprobaremos que el resultado es exactamente el mismo: "Hola Mundo".
   
Este sistema fue también utilizado por los juegos Java de los teléfonos móviles o las PDA de hace unos años. Distintas marcas de teléfonos móviles eran capaces de ejecutar el mismo juego, aun cuando cada teléfono tuviera una CPU y un sistema operativo distinto [Si, los teléfonos móviles, incluso los antiguos, tienen CPU y un rudimentario sistema operativo. Multitud de cacharros de hoy en día los tienen: reproductores de DVD, de MP3, cajeros automáticos, GPS, incluso algunos electrodomésticos]. Teléfono movil viejuno
Los smartphones y las tablets se parecen más a un ordenador que a un teléfono. El sistema Android, por ejemplo, permite que una misma aplicación programada en Java se ejecute sobre multitud de dispositivos con hardware muy distinto. Eso es porque el proceso de compilación se realiza generando un ejecutable para una imaginaria máquina virtual llamada Dalvik (o ART, en las últimas versiones), y cada dispositivo trae de fábrica un intérprete que ejecuta el programa compilado para Dalvik sobre el hardware real. smartphone

Veamos las ventajas que tiene este enfoque de máquina virtual de proceso comparado con la compilación o la interpretación. Vamos a retomar la tabla anterior, pero añadiendo la máquina virtual.

Compilar Interpretar Máquina virtual
-Genera un ejecutable -No genera un ejecutable -Genera una especie de ejecutable, pero portable entre plataformas, dirigido a una CPU imaginaria.
-El proceso de traducción se realiza una sola vez -El proceso de traducción se realiza en cada ejecución -Se realiza una sola traducción a código intermedio, y una interpretación muy rápida del código intermedio en cada ejecución.
-La ejecución es muy rápida debido a que el programa ya ha sido traducido a código máquina -La ejecución es más lenta, ya que para cada línea del programa es necesario realizar la traducción -La ejecución no es tan rápida como en la compilación tradicional ni tan lenta como en la intepretación.
-El ejecutable va dirigido a una plataforma concreta (una CPU, un sistema operativo, y quizá alguna otra consideración), siendo prácticamente imposible portarlo a otra. En ocasiones, si existe un compilador para otra plataforma, se puede recompilar el programa, aunque normalmente esto plantea serias dificultades. Los programas que se van a compilar suelen estar muy ligados a la plataforma de destino. -No hay ejecutable, así que si existe un intérprete para una plataforma concreta, el programa se podrá ejecutar en ambas. Típicamente, los programas interpretados son mucho más portables que los compilados, ya que suelen existir intérpretes del mismo lenguaje en distintas plataformas. Los programas que se van a interpretar no suelen ser muy dependientes de su plataforma de destino, siendo más portables. -El ejecutable va dirigido a una CPU imaginaria. Se puede transportar a una plataforma para la cual exista una "máquina virtual" (el intérprete de código intermedio)
-Los lenguajes compilados suelen proporcionar al programador mecanismos más potentes y flexibles, a costa de una mayor ligazón a la plataforma. -Los lenguajes interpretados no suelen ser muy dependientes de la plataforma de destino, pero en contrapartida suelen ser menos flexibles y potentes que los compilados. -La plataforma de destino es virtual. Así pues, los programas son dependientes de esta plataforma virtual, que es emulada luego sobre plataformas reales por la "maquina virtual".
-Una vez compilado el programa, el código fuente no es necesario para ejecutarlo, así que puede permanecer en secreto si se desea. -El código fuente es necesario en cada ejecución, así que no puede permanecer en secreto -El código fuente no es necesario para la ejecución, sólo el código intermedio.
-Los errores sintácticos se detectan durante la compilación. Si el fuente contiene errores sintácticos, el compilador no producirá un ejecutable. -Los errores sintácticos se detectan durante la ejecución, ya que traducción y ejecución se van haciendo simultáneamente. Algún error sintáctico podría quedar enmascarado, si para una ejecución concreta no es necesario traducir la línea que lo contiene. (Algunos intérpretes son capaces de evitar esto) -Los errores sintácticos se detectan durante la compilación.
-Un programa compilado puede, por error, afectar seriamente a la estabilidad de la plataforma, comprometiendo la ejecución de los otros procesos, por ejemplo, acaparando la CPU, la memoria o algún otro recurso, siendo a veces complicado para el sistema operativo interrumpir su ejecución. -Un programa interpretado con un comportamiento torpe normalmente puede ser interrumpido sin dificultad, ya que su ejecución está bajo el control del intérprete, y no sólo del sistema operativo. -Un programa con un comportamiento torpe es ejecutado sobre la máquina virtual, que tiene un control absoluto sobre él, con lo que no se suele comprometer la estabilidad de la plataforma real.

En definitiva, compilación e interpretación son las opciones típicas para los lenguajes de programación más tradicionales, presentando cada una de ellas sus ventajas e inconvenientes. El enfoque más moderno es el de máquina virtual, en el que se realiza una compilación cuya plataforma de destino es una máquina imaginaria o virtual, y el ejecutable intermedio es posteriormente interpretado en cada ejecución. El mundo de la programación va poco a poco pero sin pausa adoptando cada vez más este enfoque, ya que presenta un buen compromiso entre portabilidad y estabilidad. Este enfoque es el adoptado por los lenguajes más modernos, como Java, Visual Basic, C#, J#, y cada día surgen versiones de otros lenguajes que funcionan según este esquema, como Scala, F# o IronPython.

NOTAS:

  1. La plataforma Java, originalmente desarrollada por Sun Microsystems, y hoy propiedad de Oracle dispone de versiones para sistemas operativos Linux, Windows, Mac y Solaris, y para varias CPU. Cabe mencionar que existen otras implementaciones de Java que no han sido desarrolladas por Oracle, aunque en su mayor parte muy minoritarias y experimentales, con la excepción de la máquina virtual de Android.
  2. La plataforma Android utiliza Java como lenguaje de referencia, y también el enfoque de máquina virtual, pero esa máquina virtual imaginaria no es la misma que la de Oracle. La de oracle se llama JVM (Java Virtual Machine) enfocada principalmente a ordenadores. La de Android se llama Dalvik (o ART, una máquina nueva y compatible, en las últimas versiones -4.4 en adelante), y tiene una estructura distinta. Es decir, el compilador Java de Oracle compila para JVM, y su código es portable entre las plataformas con JVM: Windows, Linux, Solaris y Mac. Los programas de Android, escritos también en Java compilan para Dalvik, y se ejecutan en los dispositivos con un intérprete de Dalvik: los móviles y tablets Android.
    Por otro lado, y aunque no es muy frecuente, también es posible hacer programas compilados en android para la la plataforma nativa, es decir, para el hardware original, sin pasar por el intérprete, y en ese caso se suelen utilizar los lenguajes C o C++.
  3. La plataforma .NET, originalmente desarrollada por Microsoft dispone de versiones que en principio van orientadas sólo a sistemas operativos Windows. Es necesario mencionar que existen otras implementaciones de .NET que no han sido desarrolladas por Microsoft y que permiten el desarrollo y la ejecución en otros sistemas operativos. Es muy de destacar la plataforma Mono, que permite la ejecución y el desarrollo de proyectos .NET en otros sistemas operativos distintos de Windows. Con la participación de los productos de la empresa Xamarin, puede desarrollarse con el enfoque de la plataforma .net también para iOS y Android.