En informática , un intérprete es un programa informático que ejecuta directamente instrucciones escritas en un lenguaje de programación o scripting , sin requerir que previamente hayan sido compiladas en un programa en lenguaje de máquina . Un intérprete generalmente utiliza una de las siguientes estrategias para la ejecución del programa:
Las primeras versiones del lenguaje de programación Lisp y los dialectos BASIC de minicomputadoras y microcomputadoras serían ejemplos del primer tipo. Perl , Raku , Python , MATLAB y Ruby son ejemplos del segundo tipo, mientras que UCSD Pascal es un ejemplo del tercer tipo. Los programas fuente se compilan con anticipación y se almacenan como código independiente de la máquina, que luego se vincula en tiempo de ejecución y se ejecuta mediante un intérprete y/o compilador (para sistemas JIT ). Algunos sistemas, como Smalltalk y las versiones contemporáneas de BASIC y Java , también pueden combinar dos y tres tipos. [2] También se han construido intérpretes de varios tipos para muchos lenguajes tradicionalmente asociados con la compilación, como Algol , Fortran , Cobol , C y C++ .
Si bien la interpretación y la compilación son los dos medios principales mediante los cuales se implementan los lenguajes de programación, no son mutuamente excluyentes, ya que la mayoría de los sistemas de interpretación también realizan algún trabajo de traducción, al igual que los compiladores. Los términos " lenguaje interpretado " o " lenguaje compilado " significan que la implementación canónica de ese lenguaje es un intérprete o un compilador, respectivamente. Idealmente, un lenguaje de alto nivel es una abstracción independiente de implementaciones particulares.
Ya en 1952 se utilizaban intérpretes para facilitar la programación dentro de las limitaciones de las computadoras de la época (por ejemplo, escasez de espacio de almacenamiento de programas o falta de soporte nativo para números de punto flotante). También se utilizaron intérpretes para traducir entre lenguajes de máquina de bajo nivel, lo que permitió escribir código para máquinas que aún estaban en construcción y probarlo en computadoras que ya existían. [3] El primer lenguaje interpretado de alto nivel fue Lisp . Lisp fue implementado por primera vez por Steve Russell en una computadora IBM 704 . Russell había leído el artículo de John McCarthy , "Funciones recursivas de expresiones simbólicas y su computación por máquina, Parte I", y se dio cuenta (para sorpresa de McCarthy) de que la función de evaluación de Lisp podía implementarse en código de máquina. [4] El resultado fue un intérprete Lisp funcional que podría usarse para ejecutar programas Lisp, o más correctamente, "evaluar expresiones Lisp".
Un intérprete generalmente consta de un conjunto de comandos conocidos que puede ejecutar y una lista de estos comandos en el orden en que el programador desea ejecutarlos. Cada comando (también conocido como Instrucción ) contiene los datos que el programador desea mutar e información sobre cómo mutar los datos. Por ejemplo, un intérprete podría leerlo ADD Books, 5
e interpretarlo como una solicitud para agregar cinco a la Books
variable .
Los intérpretes tienen una amplia variedad de instrucciones que están especializadas para realizar diferentes tareas, pero comúnmente encontrará instrucciones de intérprete para operaciones matemáticas básicas , bifurcaciones y administración de memoria , lo que hace que la mayoría de los intérpretes de Turing sean completos . Muchos intérpretes también están estrechamente integrados con un recolector de basura y un depurador .
Los programas escritos en un lenguaje de alto nivel se ejecutan directamente mediante algún tipo de intérprete o se convierten en código de máquina mediante un compilador (y un ensamblador y enlazador ) para que los ejecute la CPU .
Si bien los compiladores (y ensambladores) generalmente producen código de máquina directamente ejecutable mediante hardware de computadora, a menudo (opcionalmente) pueden producir una forma intermedia llamada código objeto . Este es básicamente el mismo código específico de la máquina, pero aumentado con una tabla de símbolos con nombres y etiquetas para hacer que los bloques (o módulos) ejecutables sean identificables y reubicables. Los programas compilados normalmente utilizarán bloques de construcción (funciones) guardados en una biblioteca de dichos módulos de código objeto. Se utiliza un vinculador para combinar archivos de biblioteca (prefabricados) con los archivos objeto de la aplicación para formar un único archivo ejecutable. Por lo tanto, los archivos objeto que se utilizan para generar un archivo ejecutable a menudo se producen en diferentes momentos y, a veces, incluso en diferentes lenguajes (capaces de generar el mismo formato de objeto).
Un intérprete simple escrito en un lenguaje de bajo nivel (por ejemplo, ensamblador ) puede tener bloques de código de máquina similares que implementan funciones del lenguaje de alto nivel almacenados y ejecutados cuando la entrada de una función en una tabla de consulta apunta a ese código. Sin embargo, un intérprete escrito en un lenguaje de alto nivel normalmente utiliza otro enfoque, como generar y luego recorrer un árbol de análisis , o generar y ejecutar instrucciones intermedias definidas por software, o ambas.
Por lo tanto, tanto los compiladores como los intérpretes generalmente convierten el código fuente (archivos de texto) en tokens, ambos pueden (o no) generar un árbol de análisis y ambos pueden generar instrucciones inmediatas (para una máquina de pila , código cuádruple o por otros medios). La diferencia básica es que un sistema compilador, que incluye un enlazador (integrado o separado), genera un programa de código de máquina independiente , mientras que un sistema intérprete realiza las acciones descritas por el programa de alto nivel.
Por lo tanto, un compilador puede realizar casi todas las conversiones desde la semántica del código fuente al nivel de máquina de una vez por todas (es decir, hasta que sea necesario cambiar el programa), mientras que un intérprete tiene que realizar parte de este trabajo de conversión cada vez que se ejecuta una declaración o función. . Sin embargo, en un intérprete eficiente, gran parte del trabajo de traducción (incluido el análisis de tipos y similares) se factoriza y se realiza sólo la primera vez que se ejecuta un programa, módulo, función o incluso declaración, por lo que es muy similar a cómo se ejecuta un programa, módulo, función o incluso declaración. El compilador funciona. Sin embargo, un programa compilado aún se ejecuta mucho más rápido, en la mayoría de las circunstancias, en parte porque los compiladores están diseñados para optimizar el código y se les puede dar tiempo suficiente para ello. Esto es especialmente cierto para lenguajes de alto nivel más simples sin (muchas) estructuras de datos dinámicas, comprobaciones o verificación de tipos .
En la compilación tradicional, la salida ejecutable de los enlazadores (archivos .exe o .dll o una biblioteca, ver imagen) normalmente es reubicable cuando se ejecuta en un sistema operativo general, de manera muy similar a como lo son los módulos de código objeto, pero con la diferencia de que esta reubicación se realiza dinámicamente en tiempo de ejecución, es decir, cuando el programa se carga para su ejecución. Por otro lado, los programas compilados y vinculados para pequeños sistemas integrados suelen estar asignados estáticamente, a menudo codificados en una memoria flash NOR , ya que a menudo no hay almacenamiento secundario ni sistema operativo en este sentido.
Históricamente, la mayoría de los sistemas de interpretación han tenido incorporado un editor autónomo. Esto se está volviendo más común también para los compiladores (entonces a menudo llamados IDE ), aunque algunos programadores prefieren usar un editor de su elección y ejecutar el compilador, el enlazador y otros. herramientas manualmente. Históricamente, los compiladores son anteriores a los intérpretes porque el hardware en ese momento no podía soportar tanto el intérprete como el código interpretado y el entorno por lotes típico de la época limitaba las ventajas de la interpretación. [5]
Durante el ciclo de desarrollo de software , los programadores realizan cambios frecuentes en el código fuente. Cuando se utiliza un compilador, cada vez que se realiza un cambio en el código fuente, se debe esperar a que el compilador traduzca los archivos fuente modificados y vincule todos los archivos de código binario antes de que se pueda ejecutar el programa. Cuanto más grande sea el programa, más larga será la espera. Por el contrario, un programador que utiliza un intérprete espera mucho menos, ya que el intérprete normalmente sólo necesita traducir el código en el que se está trabajando a una representación intermedia (o no traducirlo en absoluto), por lo que se requiere mucho menos tiempo antes de que se puedan realizar los cambios. probado. Los efectos son evidentes al guardar el código fuente y recargar el programa. El código compilado generalmente se depura con menor facilidad, ya que la edición, la compilación y la vinculación son procesos secuenciales que deben realizarse en la secuencia adecuada con un conjunto adecuado de comandos. Por esta razón, muchos compiladores también tienen una ayuda ejecutiva, conocida como Makefile y programa. El Makefile enumera las líneas de comando del compilador y del vinculador y los archivos de código fuente del programa, pero puede tomar una entrada de menú de línea de comando simple (por ejemplo, "Make 3") que selecciona el tercer grupo (conjunto) de instrucciones y luego envía los comandos al compilador y al vinculador. alimentando los archivos de código fuente especificados.
Un compilador convierte el código fuente en instrucciones binarias para la arquitectura de un procesador específico, haciéndolo menos portátil . Esta conversión se realiza solo una vez, en el entorno del desarrollador, y luego el mismo binario se puede distribuir a las máquinas del usuario, donde se puede ejecutar sin traducción adicional. Un compilador cruzado puede generar código binario para la máquina del usuario incluso si tiene un procesador diferente al de la máquina donde se compila el código.
Un programa interpretado se puede distribuir como código fuente. Debe traducirse en cada máquina final, lo que lleva más tiempo pero hace que la distribución del programa sea independiente de la arquitectura de la máquina. Sin embargo, la portabilidad del código fuente interpretado depende de que la máquina de destino tenga realmente un intérprete adecuado. Si es necesario suministrar el intérprete junto con el código fuente, el proceso general de instalación es más complejo que la entrega de un ejecutable monolítico, ya que el intérprete en sí es parte de lo que debe instalarse.
El hecho de que los humanos puedan leer y copiar fácilmente el código interpretado puede ser motivo de preocupación desde el punto de vista de los derechos de autor . Sin embargo, existen varios sistemas de cifrado y ofuscación . La entrega de código intermedio, como el código de bytes, tiene un efecto similar a la ofuscación, pero el código de bytes podría decodificarse con un descompilador o desensamblador . [ cita necesaria ]
La principal desventaja de los intérpretes es que un programa interpretado normalmente se ejecuta más lentamente que si hubiera sido compilado . La diferencia de velocidades puede ser pequeña o grande; a menudo un orden de magnitud y a veces más. Generalmente lleva más tiempo ejecutar un programa bajo un intérprete que ejecutar el código compilado, pero puede tomar menos tiempo interpretarlo que el tiempo total requerido para compilarlo y ejecutarlo. Esto es especialmente importante al crear prototipos y probar código, cuando un ciclo de edición-interpretación-depuración a menudo puede ser mucho más corto que un ciclo de edición-compilación-ejecución-depuración. [6] [7]
Interpretar el código es más lento que ejecutar el código compilado porque el intérprete debe analizar cada declaración en el programa cada vez que se ejecuta y luego realizar la acción deseada, mientras que el código compilado simplemente realiza la acción dentro de un contexto fijo determinado por la compilación. Este análisis en tiempo de ejecución se conoce como "sobrecarga interpretativa". El acceso a las variables también es más lento en un intérprete porque el mapeo de identificadores a ubicaciones de almacenamiento debe realizarse repetidamente en tiempo de ejecución en lugar de en tiempo de compilación . [6]
Existen varios compromisos entre la velocidad de desarrollo cuando se usa un intérprete y la velocidad de ejecución cuando se usa un compilador. Algunos sistemas (como algunos Lisps ) permiten que el código interpretado y compilado se llame entre sí y comparta variables. Esto significa que una vez que una rutina ha sido probada y depurada bajo el intérprete, se puede compilar y así beneficiarse de una ejecución más rápida mientras se desarrollan otras rutinas. [ cita necesaria ] Muchos intérpretes no ejecutan el código fuente tal como está, sino que lo convierten a una forma interna más compacta. Muchos intérpretes de BASIC reemplazan las palabras clave con tokens de un solo byte que pueden usarse para encontrar la instrucción en una tabla de salto . [6] Algunos intérpretes, como el intérprete PBASIC , logran niveles aún más altos de compactación del programa mediante el uso de una estructura de memoria de programa orientada a bits en lugar de orientada a bytes, donde los tokens de comando ocupan quizás 5 bits, nominalmente "16 bits". Las constantes se almacenan en un código de longitud variable que requiere 3, 6, 10 o 18 bits, y los operandos de dirección incluyen un "desplazamiento de bits". Muchos intérpretes de BASIC pueden almacenar y leer su propia representación interna tokenizada.
Un intérprete bien podría utilizar el mismo analizador léxico y analizador sintáctico que el compilador y luego interpretar el árbol de sintaxis abstracta resultante . En el cuadro se muestran ejemplos de definiciones de tipos de datos para este último y un intérprete de juguete para árboles de sintaxis obtenidos a partir de expresiones C.
La interpretación no se puede utilizar como único método de ejecución: aunque un intérprete puede interpretarse a sí mismo, etc., se necesita un programa ejecutado directamente en algún lugar al final de la pila porque el código que se interpreta no es, por definición, el mismo que el código de máquina que la CPU puede ejecutar. [8] [9]
Existe un espectro de posibilidades entre interpretar y compilar, dependiendo de la cantidad de análisis realizado antes de ejecutar el programa. Por ejemplo, Emacs Lisp está compilado en código de bytes , que es una representación altamente comprimida y optimizada del código fuente de Lisp, pero no es código de máquina (y por lo tanto no está vinculado a ningún hardware en particular). Este código "compilado" luego es interpretado por un intérprete de código de bytes (escrito en C ). El código compilado en este caso es código de máquina para una máquina virtual , que no se implementa en el hardware, sino en el intérprete de código de bytes. Estos intérpretes compiladores a veces también se denominan comprensores . [10] [11] En un intérprete de código de bytes cada instrucción comienza con un byte y, por lo tanto, los intérpretes de código de bytes tienen hasta 256 instrucciones, aunque no todas pueden usarse. Algunos códigos de bytes pueden ocupar varios bytes y pueden ser arbitrariamente complicados.
Las tablas de control , que no necesariamente necesitan pasar por una fase de compilación, dictan el flujo de control algorítmico apropiado a través de intérpretes personalizados de manera similar a los intérpretes de código de bytes.
Los intérpretes de código subprocesos son similares a los intérpretes de código de bytes, pero en lugar de bytes utilizan punteros. Cada "instrucción" es una palabra que apunta a una función o secuencia de instrucciones, posiblemente seguida de un parámetro. El intérprete de código enhebrado realiza un bucle para buscar instrucciones y llamar a las funciones a las que apuntan, o busca la primera instrucción y salta a ella, y cada secuencia de instrucciones termina con una búsqueda y un salto a la siguiente instrucción. A diferencia del código de bytes, no existe un límite efectivo en la cantidad de instrucciones diferentes además de la memoria disponible y el espacio de direcciones. El ejemplo clásico de código enhebrado es el código Forth utilizado en los sistemas Open Firmware : el lenguaje fuente se compila en un "código F" (un código de bytes), que luego es interpretado por una máquina virtual . [ cita necesaria ]
En el espectro entre la interpretación y la compilación, otro enfoque es transformar el código fuente en un árbol de sintaxis abstracta optimizado (AST) y luego ejecutar el programa siguiendo esta estructura de árbol o usarlo para generar código nativo justo a tiempo . [12] En este enfoque, cada oración debe analizarse solo una vez. Como ventaja sobre el código de bytes, el AST mantiene la estructura global del programa y las relaciones entre declaraciones (que se pierden en una representación de código de bytes) y, cuando se comprime, proporciona una representación más compacta. [13] Por lo tanto, se ha propuesto el uso de AST como un mejor formato intermedio para compiladores justo a tiempo que el código de bytes. Además, permite que el sistema realice un mejor análisis durante el tiempo de ejecución.
Sin embargo, para los intérpretes, un AST causa más sobrecarga que un intérprete de código de bytes, debido a que los nodos relacionados con la sintaxis no realizan ningún trabajo útil, a una representación menos secuencial (que requiere atravesar más punteros) y a la sobrecarga al visitar el árbol. [14]
Para desdibujar aún más la distinción entre intérpretes, intérpretes de código de bytes y compilación está la compilación justo a tiempo (JIT), una técnica en la que la representación intermedia se compila en código de máquina nativo en tiempo de ejecución. Esto confiere la eficiencia de ejecutar código nativo, a costa del tiempo de inicio y un mayor uso de memoria cuando se compila por primera vez el código de bytes o AST. El primer compilador JIT publicado generalmente se atribuye al trabajo en LISP de John McCarthy en 1960. [15] La optimización adaptativa es una técnica complementaria en la que el intérprete perfila el programa en ejecución y compila sus partes ejecutadas con más frecuencia en código nativo. Esta última técnica tiene algunas décadas y apareció en lenguajes como Smalltalk en la década de 1980. [16]
La compilación justo a tiempo ha ganado la atención generalizada entre los implementadores de lenguajes en los últimos años, con Java , .NET Framework , las implementaciones de JavaScript más modernas y Matlab que ahora incluyen compiladores JIT. [ cita necesaria ]
Para hacer aún más vaga la distinción entre compiladores e intérpretes, existe un diseño de intérprete especial conocido como intérprete de plantilla. En lugar de implementar la ejecución de código en virtud de una declaración de cambio grande que contiene todos los códigos de bytes posibles, mientras opera en una pila de software o en un recorrido de árbol, un intérprete de plantilla mantiene una gran variedad de códigos de bytes (o cualquier representación intermedia eficiente) asignados directamente a los correspondientes. Instrucciones de máquina nativas que se pueden ejecutar en el hardware del host como pares clave-valor (o en diseños más eficientes, direcciones directas a las instrucciones nativas), [17] [18] conocidas como "Plantilla". Cuando se ejecuta el segmento de código particular, el intérprete simplemente carga o salta a la asignación del código de operación en la plantilla y lo ejecuta directamente en el hardware. [19] [20] Debido a su diseño, el intérprete de plantilla se parece mucho a un compilador justo a tiempo en lugar de a un intérprete tradicional; sin embargo, técnicamente no es un JIT debido al hecho de que simplemente traduce código del lenguaje a Native llama un código de operación a la vez en lugar de crear secuencias optimizadas de instrucciones ejecutables de la CPU a partir de todo el segmento de código. Debido al diseño simple del intérprete de simplemente pasar llamadas directamente al hardware en lugar de implementarlas directamente, es mucho más rápido que cualquier otro tipo, incluso los intérpretes de código de bytes, y hasta cierto punto menos propenso a errores, pero como compensación es más difícil de mantener debido a que el intérprete tiene que admitir la traducción a múltiples arquitecturas diferentes en lugar de una máquina/pila virtual independiente de la plataforma. Hasta la fecha, las únicas implementaciones de intérpretes de plantillas de lenguajes ampliamente conocidos que existen son el intérprete dentro de la implementación de referencia oficial de Java, Sun HotSpot Java Virtual Machine [17] y Ignition Interpreter en el motor de ejecución de JavaScript Google V8.
Un autointérprete es un intérprete de lenguaje de programación escrito en un lenguaje de programación que puede interpretarse a sí mismo; un ejemplo es un intérprete de BASIC escrito en BASIC. Los autointérpretes están relacionados con los compiladores autohospedados .
Si no existe un compilador para el lenguaje que se va a interpretar, la creación de un autointérprete requiere la implementación del lenguaje en un lenguaje anfitrión (que puede ser otro lenguaje de programación o ensamblador ). Al contar con un primer intérprete como este, el sistema se inicia y se pueden desarrollar nuevas versiones del intérprete en el propio idioma. Fue así como Donald Knuth desarrolló el intérprete TANGLE para el lenguaje WEB del sistema tipográfico estándar de facto TeX .
La definición de un lenguaje informático generalmente se realiza en relación con una máquina abstracta (la llamada semántica operativa ) o como una función matemática ( semántica denotacional ). Un idioma también puede ser definido por un intérprete en el que se proporciona la semántica del idioma anfitrión. La definición de una lengua por parte de un autointérprete no está bien fundada (no puede definir una lengua), pero un autointérprete le dice al lector sobre la expresividad y elegancia de una lengua. También permite al intérprete interpretar su código fuente, el primer paso hacia la interpretación reflexiva.
Una dimensión de diseño importante en la implementación de un autointérprete es si una característica del lenguaje interpretado se implementa con la misma característica en el idioma anfitrión del intérprete. Un ejemplo es si un cierre en un lenguaje tipo Lisp se implementa utilizando cierres en el lenguaje del intérprete o se implementa "manualmente" con una estructura de datos que almacena explícitamente el entorno. Cuantas más funciones implemente la misma función en el idioma anfitrión, menos control tendrá el programador del intérprete; por ejemplo, no se puede lograr un comportamiento diferente para tratar con desbordamientos numéricos si las operaciones aritméticas se delegan a las operaciones correspondientes en el idioma anfitrión.
Algunos lenguajes como Lisp y Prolog tienen autointérpretes elegantes. [21] Se han realizado muchas investigaciones sobre autointérpretes (particularmente intérpretes reflexivos) en el lenguaje de programación Scheme , un dialecto de Lisp. Sin embargo, en general, cualquier lenguaje completo de Turing permite escribir su propio intérprete. Lisp es uno de esos lenguajes, porque los programas Lisp son listas de símbolos y otras listas. XSLT es uno de esos lenguajes porque los programas XSLT están escritos en XML. Un subdominio de la metaprogramación es la escritura de lenguajes de dominio específico (DSL).
Clive Gifford introdujo [22] una medida de la calidad del autointérprete (la eigenratio), el límite de la relación entre el tiempo de computadora dedicado a ejecutar una pila de N autointérpretes y el tiempo dedicado a ejecutar una pila de N − 1 autointérpretes como N va al infinito. Este valor no depende del programa que se esté ejecutando.
El libro Estructura e interpretación de programas de computadora presenta ejemplos de interpretación metacircular para Scheme y sus dialectos. Otros ejemplos de lenguajes con autointérprete son Forth y Pascal .
El microcódigo es una técnica muy utilizada "que impone un intérprete entre el hardware y el nivel arquitectónico de una computadora". [23] Como tal, el microcódigo es una capa de instrucciones a nivel de hardware que implementan instrucciones de código de máquina de nivel superior o secuenciación de máquina de estado interna en muchos elementos de procesamiento digital . El microcódigo se utiliza en unidades centrales de procesamiento de uso general , así como en procesadores más especializados como microcontroladores , procesadores de señales digitales , controladores de canales , controladores de disco , controladores de interfaz de red , procesadores de red , unidades de procesamiento de gráficos y en otro hardware.
El microcódigo normalmente reside en una memoria especial de alta velocidad y traduce instrucciones de máquina, datos de máquina de estado u otras entradas en secuencias de operaciones detalladas a nivel de circuito. Separa las instrucciones de la máquina de la electrónica subyacente para que las instrucciones puedan diseñarse y modificarse más libremente. También facilita la creación de instrucciones complejas de varios pasos, al tiempo que reduce la complejidad de los circuitos informáticos. La escritura de microcódigo a menudo se denomina microprogramación y el microcódigo en una implementación de procesador particular a veces se denomina microprograma .
Una microcodificación más extensa permite que microarquitecturas pequeñas y simples emulen arquitecturas más potentes con una longitud de palabra más amplia , más unidades de ejecución , etc., lo cual es una forma relativamente sencilla de lograr compatibilidad de software entre diferentes productos en una familia de procesadores.
Incluso un procesador de computadora sin microcodificación puede considerarse como un intérprete de ejecución inmediata que está escrito en un lenguaje de descripción de hardware de propósito general como VHDL para crear un sistema que analiza las instrucciones del código de máquina y las ejecuta inmediatamente.
Actualmente se necesitan intérpretes, como los escritos en Java, Perl y Tcl, para una amplia gama de tareas computacionales, incluida la emulación binaria y las aplicaciones de Internet. El rendimiento de los intérpretes sigue siendo una preocupación a pesar de su adaptabilidad, especialmente en sistemas con recursos de hardware limitados. Los enfoques avanzados de instrumentación y seguimiento brindan información sobre las implementaciones de intérpretes y la utilización de recursos del procesador durante la ejecución a través de evaluaciones de intérpretes adaptadas al conjunto de instrucciones MIPS y lenguajes de programación como Tcl, Perl y Java. Las características de rendimiento están influenciadas por la complejidad del intérprete, como lo demuestran las comparaciones con el código compilado. Está claro que el desempeño del intérprete depende más de los matices y las necesidades de recursos del intérprete que de la aplicación particular que se está interpretando.