Documentos de Académico
Documentos de Profesional
Documentos de Cultura
Compiladores y Preprocesadores de Lenguajes
Compiladores y Preprocesadores de Lenguajes
INTRODUCCIÓN Y DEFINICIONES
CONCEPTO DE TRADUCTOR
A menudo utilizamos como sinónimos los términos “traductor” y “compilador”, aunque para
ser precisos, y como veremos más adelante, un compilador es un caso particular de un traductor en
el que el lenguaje objeto es el lenguaje máquina.
Como podemos ver, un traductor es un programa como otro cualquiera que procesa unos
datos para producir un resultado. La peculiaridad es que los datos que procesa son el texto de otros
programas, mientras que el resultado que produce son, a su vez, otros programas.
Hay muchos tipos de lenguajes fuentes: desde lenguajes de propósito general como C++ o
Java, hasta lenguajes especializado para prácticamente todas las áreas de aplicación de los
ordenadores. También los lenguajes objeto varían mucho: desde lenguajes de alto nivel, hasta
ensamblador o lenguaje máquina de cada uno de los procesadores de los múltiples ordenadores que
existen en el mercado (desde microordenadores hasta los grandes ordenadores).
A veces se clasifican los traductores según su organización interna o según el propósito que
persigan como: Traductores de un solo paso, de dos pasos, incrementales, con optimización de
código, depuradores etc. A pesar de esta complejidad la mayoría de traductores realizan,
básicamente, las mismas tareas utilizando técnicas parecidas.
1
Durante el proceso se maneja una tabla de símbolos donde se almacena información sobre
cada uno de los símbolos que aparecen en el programa, y también se detecta y avisa al usuario de
los posibles errores que puedan surgir durante la traducción.
Es difícil fijar los orígenes de un término o idea. Se suele estar tentado a señalar fechas e
hitos históricos pero una investigación más cuidadosa muestra que las ideas no surgen de golpe de la
nada por la acción milagrosa de una persona especialmente brillante, sino que se desarrollan en un
ambiente apropiado y que se suele dar más bien una evolución gradual hasta llegar a la idea final.
FORTRAN:
En nuestro caso, como hito histórico, podríamos dar la fecha de Enero de 1954 en que Ziller
y Backus, de la compañía IBM, comenzaron el trabajo del compilador de FORTRAN. Para Abril ya se
les había unido Harlan Herrick que fue otro de los que firmaron el artículo “IBM 701 Speedcoding and
other Automatic Programming systems” que se presentó en el simposium de la ONR (Office of Naval
Research) celebrado el 13 y 14 de Mayo de 1954.
En dicho artículo se apunta la necesidad de un compilador tal y como se conoce hoy en día.
Allí se decía que a un programador: “le gustaría escribir X + Y en vez de (código máquina)”. De
hecho, no podía considerarse poco razonable que el programador quisiese producir, únicamente, las
fórmulas para las soluciones numéricas de su problema, y, quizás, un plan que muestre cómo se
mueven los datos desde un almacenamiento a otro y a continuación pedir que la máquina produzca
los resultados de su problema.
El artículo continuaba resaltando que: “la cuestión es si una máquina puede traducir un
lenguaje matemático suficientemente expresivo en un programa lo suficientemente económico a un
coste los suficientemente bajo como para hacer todo el proceso factible. Consideremos las ventajas
que representaría el ser capaz de describir los cálculos … para un problema utilizando un lenguaje
matemático conciso y en gran medida natural”.
2
pruebas finales del compilador, que supuso tres años de duro trabajo (hasta Abril de 1957) a un
equipo que llegó a contar con hasta nueve personas como principales diseñadores y programadores
de las seis secciones de que constaba el compilador de FORTRAN I. El compilador tuvo una enorme
acogida y se utilizó ampliamente. La compañía IBM y el lenguaje FORTRAN tuvieron un crecimiento
paralelo.
El compilador de FORTRAN tuvo una gran influencia, en parte, porque estuvo disponible
para un ordenador que era muy utilizado en aquel tiempo. Anteriormente a él, pero sin tanta
trascendencia popular, es el trabajo del primer compilador algebraico que crearon J. Halcombe
Laning Jr. Y Neal Zierler en el ordenador WHIRLWIND del MIT en Enero de 1954. Existen
fundamentos para creer que ya habían demostrado la posibilidad de la compilación algebraica en
1952, aunque el clero informático no prestó ninguna atención a la clarividencia de Laning. En
palabras del propio Backus: “causa estupor el darse cuenta de que esta negación y oscuridad fue la
reacción del clero informático a un concepto elegante implementado elegantemente”.
CONCEPTO DE COMPILADOR:
Volviendo a nuestra historia aún más para atrás podemos comprobar que la palabra
compilador existía antes de dicha fecha pero se utilizaba con un significado diferente del actual. De
entre estos “compiladores” el más elaborado no era más que un programa ensamblador muy
primitivo que insertaba parámetros en esqueletos de programas a partir de unas especificaciones
simples. En algunos casos estos “compiladores” permitían escribir instrucciones en forma abreviada,
de manera que una palabra llegaba a sustituir hasta a siete palabras. El usuario tenía que asignar
manualmente direcciones absolutas a todas las direcciones simbólicas y relativas que utilizaba.
Además de insertar direcciones y parámetros en el código, reemplazar las direcciones relativas y
simbólicas por valores suministrados por el usuario y la traducción de instrucciones abreviadas por su
forma completa. El compilador segmentaba el programa para que cupiese en la memoria disponible,
realizaba algunas comprobaciones, y buscaba y actualizaba cintas con librerías de rutinas.
Posteriormente, y cerca del final de la segunda guerra mundial, Konrad Zuse se encontró con
que las bombas aliadas habían destruido casi todos los complicados computadores de válvulas que
3
había estado construyendo en Alemania desde 1936, así que se retiró a un pequeño pueblo de los
Alpes y allí prosiguió con sus estudios teóricos, desarrollando un lenguaje muy sofisticado para
descripción de algoritmos que llamó PLANKALKUL, y que era una extensión del cálculo de predicados
y del cálculo de proposiciones de Hilbert.
Al otro lado del Atlántico, Herman H. Goldstein y John Von Neumann se enfrentaban al
mismo problema que Zuse: Cómo representar los algoritmos de forma precisa y a mayor nivel que el
lenguaje máquina. Su respuesta consistió en un lenguaje gráfico al que llamaron diagrama de flujo.
Durante 1946 y 1947 prepararon un tratado de programación basado en las ideas de los diagramas
de flujo y, aunque no fue publicado en las revistas de su tiempo, sí que se distribuyeron copias entre
la mayoría de los técnicos que utilizaban ordenadores por aquel entonces. Esto unido a su buena
presentación y al prestigio de Von Neumann hizo que su trabajo tuviese un enorme impacto y que
fuese el fundamento de las técnicas de programación en todo el mundo. La expresión diagrama de
flujo ha entrado a formar parte de los términos informáticos usuales, no obstante los diagramas de
flujo originales de Von Neumann no se parecían mucho a los que estamos acostumbrados a utilizar
hoy en día.
En 1950 describía así la motivación para este trabajo: “Von Neumann y Goldstein han
apuntado que, tal y como se confeccionan actualmente los programas, no deberíamos utilizar la
técnica de composición de programas (es decir, subrutinas) para realizar programas sencillos – que
podrían ser programados directamente – sino para evitar repeticiones al formar programas de cierta
complejidad. No obstante, existen tres buenas razones para volver a insistir en la confección de los
programas más simples a partir de otros programas básicos (es decir, lenguaje máquina), a saber: (1)
La experiencia en lógica y en matemáticas muestra a menudo se obtiene una mejor visión de los
principios considerando casos demasiado sencillos como para que sean de utilidad práctica … (2) Es
bastante posible que la técnica de composición de programas pueda reemplazar completamente a
4
los elaborados métodos de Goldstein y Neumann; merece la pena estudiar esta posibilidad aún en el
caso de que finalmente no funcionase. (3) La técnica de la composición de programas puede ser
mecanizada; si se considerase conveniente construir los programas, o por lo menos algunos de ellos,
por máquinas, lo más probable es que esto se realizase analizándolos hasta descomponerlos en
programas básicos”.
Los tres lenguajes anteriores, Plancálculo, Diagramas de flujo y lenguaje de Curry nunca
fueron implementados. Eran simplemente herramientas conceptuales durante el proceso de
programación. Estas herramientas conceptuales, evidentemente muy importantes, dejaban mucho
trabajo mecánico a realizar por los programadores.
Posteriormente se desarrollaron otros lenguajes de menor nivel pero que tenían una
traducción inmediata a código máquina, como era el caso del “Short Code”, propuesto en 1949,
programado a finales de 1950 y revisado en Enero de 1952.
- "Uno podría decir que Turing, cuando probó que la máquina universal de cómputo podía hacer
cualquier cosa que pudiese realizar un computador, debía haber entendido la idea de que los
ordenadores son máquinas manipuladoras de símbolos. Pero releyendo cuidadosamente el
informe de Burks, Goldstein y Von Neumann sobre programación, he llegado al convencimiento
de que consideraban a los ordenadores como machacadores de números. Simplemente no
existe ninguna indicación de manipulación general de símbolos. En el famoso libro de
programación de Wilkes y colaboradores, encontré en el apéndice D el germen de la idea de un
intérprete. Y fue de esta forma, por un cuidadoso y largo estudio, que llegué a aprender que los
computadores son manipuladores generales de símbolos.
Un examen de la historia de la computación muestra que alrededor de los años 1952 – 1954
muchos de nosotros llegamos a la misma conclusión de que el ordenador era más que un
machacador de números. Recuerdo a Al Perlis señalando que un número de buenos científicos
habían estado trabajando durante años con máquinas y que repentinamente recibieron el
mensaje y fueron a predicar el evangelio. Y estoy seguro de que encontraron lo mismo que yo
encontré, la audiencia podía asentir con la cabeza pero uno se daba cuenta de que no
entendían el mensaje. El concepto era difícil de aprehender cuando, durante años, no has hecho
más que machacar números. Ahora sí es fácil de captar y los estudiantes dan fácilmente este
paso que a nosotros nos costó tanto avanzar. No obstante aún lo tienen que dar en algún
momento”.
5
EL COMPILADOR COMO PROGRAMACIÓN AUTOMÁTICA:
Por lo tanto, para el año 1954 ya se habían desarrollado lenguajes para la descripción de
algoritmos, para llegar a utilizar a estos lenguajes como lenguajes de programación era necesario
entender que los ordenadores son manipuladores de símbolos y por lo tanto pueden procesar la
sintaxis de los lenguajes de programación. Esta idea se puso en práctica aprovechando los sistemas
de ayuda a la programación y las técnicas de programación por subrutinas para dar lugar a lo que se
conoce actualmente por compilador.
A pesar de todo, los inicios de los sistemas de traducción no fueron un camino de rosas
puesto que se jugaban intereses creados. Oigamos a John Backus:
- “La programación a comienzos de los años 50 era un arte oscuro, una materia privada y arcana
que involucraba solamente a un programador, un problema, un computador y quizás una
pequeña librería de subrutinas y un programa ensamblador primitivo.
6
ENTORNO DE EJECUCIÓN DE UN COMPILADOR
Además del compilador suelen ser necesarias otras herramientas para crear el programa
ejecutable. Por ejemplo un programa fuente puede estar escrito en módulos separados. La tarea de
juntar estos módulos se encarga a veces a un preprocesador que, además, expande macros en
sentencias del lenguaje. Estos módulos se compilan produciendo programas en ensamblador que, a
continuación, se ensamblan produciendo módulos en lenguaje máquina que necesitan linkarse con
las rutinas, y otros módulos, para dar un programa máquina reubicable que aún necesita de un
cargador para ejecutarse.
7
Preprocesadores.
Los preprocesadores proporcionan la entrada para los traductores y pueden realizar las
siguientes funciones:
Ensambladores.
Algunos traductores producen lenguaje ensamblador que habrá que ensamblar después,
mientras que otros producen directamente lenguaje máquina reubicable que puede pasar al
enlazador. Los ensambladores son compiladores que admiten como lenguaje fuente el lenguaje
ensamblador y como lenguaje objeto el lenguaje máquina de un cierto procesador. El lenguaje
ensamblador es una versión simbólica para el lenguaje máquina en la que se utilizan nombres
nemotécnicos en vez de códigos binarios de operación y nombres simbólicos en vez de direcciones
de memoria.
Un cargador toma un código máquina reubicable, altera las direcciones, según el proceso
descrito anteriormente, y sitúa el código ya alterado y los datos en memoria en la dirección que
precisa. El enlazador crea un único archivo con programa objeto reubicable de varios archivos
conteniendo código máquina reubicable (cada archivo es el resultado de una compilación de un
módulo diferente del programa) y de rutinas de librería suministradas por el sistema.
Para que los diferentes módulos puedan utilizarse juntos, es necesario que existan algunas
referencias externas. Es decir, el código de un módulo puede utilizar un dato o saltar a una dirección
que está definida en otro módulo diferente.
Es corriente que estos lenguajes posean palabras claves como PUBLIC y EXTERNAL, de forma
que cuando un módulo quiere utilizar un dato o saltar a una dirección de un procedimiento que está
definida en otro módulo diferente, debe declararlos como EXTERNAL, mientras que el módulo que
quiere permitir que otros módulos utilicen sus datos o sus procedimientos debe declararlos PUBLIC.
8
Nótese que hemos distinguido entre definir un dato o procedimiento (reservar espacio para
el dato y especificar el código del procedimiento), y declararlo que consiste en especificar el tipo de
dato y otras características que necesita el compilador para generar código (pero sin reservar espacio
en memoria para el dato ni especificar el código de ningún procedimiento, espacio y código que
deben estar reservados y especificados en otro lugar del mismo módulo o en otro módulo diferente).
Podemos considerar que un traductor trabaja en fases, cada una de las cuales transforma el
programa fuente de una representación a otra. En el gráfico se muestra una descomposición típica
de un traductor. En la práctica algunas de las fases pueden estar agrupadas, como se menciona más
adelante, y puede que no lleguen a construirse explícitamente las representaciones intermedias entre
las fases agrupadas.
Las tres primeras fases suelen agruparse en lo que se conoce como la sección de análisis de
un traductor, mientras que las tres últimas fases se consideran la sección de síntesis del traductor.
También se muestran dos actividades (manejo de la tabla de símbolos y manejo de errores) que, en
puridad, no pueden considerarse “fases”. Estas actividades interactúan con las seis fases del traductor
(analizador léxico, analizador sintáctico, analizador semántico, generador de código intermedio,
optimizador de código y generador de código).
- Análisis lineal: según el cual se lee de izquierda a derecha la cadena de caracteres que
constituye el programa fuente y se agrupan dichos caracteres para formar los tokens, que
son cadenas de caracteres con un significado conjunto. El análisis lineal lo realiza el
analizador léxico (también llamado lexicográfico). Los tokens constituyen los datos de
entrada para el análisis jerárquico.
- Análisis jerárquico: los tokens se agrupan jerárquicamente para formar conjuntos anidados
con un significado colectivo. El análisis jerárquico lo realiza el analizador sintáctico.
- Análisis semántico, o de restricciones contextuales: se comprueba que los componentes del
programa encajan adecuadamente en el contexto en que están utilizados. Aquí se
comprueban una serie de requisitos que no se pueden expresar mediante métodos
sintácticos, como son: que cada variable haya sido declarada antes de ser utilizada, o que el
tipo de los operandos en una expresión sean correctos.
9
Análisis léxico.
Es la primera fase del compilador y la única que tiene acceso al texto del programa fuente.
La representación interna del programa cambia conforme avanza la compilación. El analizador léxico
lee la secuencia de caracteres del programa fuente y la convierte en una secuencia de tokens. Por
ejemplo, si la entrada fuese la sentencia de asignación en lenguaje C
El analizador leería dicha secuencia de caracteres y los agruparía formando tokens. Cada
token representa una secuencia de caracteres lógicamente coherentes. Por ejemplo un identificador,
una constante entera, un operador, una palabra como WHILE ó FOR, signos de puntuación, un
operador formado por varios caracteres como ++. En nuestro caso el analizador léxico agruparía los
caracteres formando los tokens siguientes:
10
- El identificador energia
- El operador de asignación = (ASIGN en lo sucesivo)
- El identificador total
- El operador de asignación = (ASIGN en lo sucesivo)
- El identificador cantidad
- El operador de suma +
- La constante numérica entera 23
Otra labor del analizador léxico es eliminar los espacios blancos que separan los tokens.
La secuencia de caracteres que forman el token se llama un lexema. Los tokens son
conceptos generales como CTE ENTERA, IDENTIFICADOR, OPERADOR, etc. Mientras que los lexemas
son instancias particulares de los tokens como por ejemplo los números 1234 ó 4453.
Para algunos tokens, como por ejemplo aquellos asociados con una palabra clave, existirá un
único lexema posible. Los lexemas pueden considerarse como atributos de los tokens.
A continuación se muestra una tabla con: algunos tokens, los patrones que describen al
token y los lexemas:
A la mayoría de los token les añadimos un atributo, también llamado valor léxico. Por
ejemplo, el valor léxico del identificador “energía” suele ser la cadena con el nombre. Para el token
CTEENTERA, de nuestro ejemplo, su valor léxico puede ser el número 23.
11
Análisis sintáctico.
Constituye el análisis jerárquico. También se llama parsing. Consiste en agrupar los tokens
del programa fuente formando frases gramaticales que son utilizadas por el traductor para sintetizar
el resultado. Lo normal es representar las frases gramaticales del programa fuente en forma de árbol
de análisis (también llamados árboles de sintaxis concreta, árboles de parse, o parse tree), como el
que se muestra más adelante.
Notar que, en la expresión “energia = total = cantidad + 23”, la frase “cantidad + 23” forma
una unidad lógica ya que se entiende que la suma se realiza antes que la asignación. Por eso hemos
agrupado “total = cantidad” ya que va seguida del signo de suma.
Árbol de análisis:
La estructura jerárquica anterior suele describirse mediante reglas recursivas. Por ejemplo,
puede que tuviésemos las siguientes reglas como parte de la definición de lo que es una expresión:
12
1. Cualquier identificador constituye una expresión.
2. Cualquier constante entera constituye una expresión.
3. Si expre1 y expr2 son dos expresiones, también lo serán:
identificador = expr1
expr1 * expr2
expr1 + expr2
(expr1)
Las reglas 1 y 2 son las reglas básicas no recursivas, mientras que la regla 3 es recursiva al
definir una expresión en términos de expresiones unidas por operadores. Por lo tanto, total y
cantidad son expresiones según la regla 1. La constante entera 23 es una expresión según la regla 2.
De la regla 3 podemos deducir que cantidad es una expresión y, a continuación, que cantidad + 23
es otra expresión, así como que total = cantidad + 23 es, también, otra expresión.
Muchos lenguajes definen lo que es una sentencia utilizando también reglas recursivas como
por ejemplo:
Son sentencias.
La división entre análisis léxico y análisis sintáctico es, hasta cierto punto, arbitraria. En
realidad elegimos una división que simplifica globalmente la tarea del análisis. Un factor a tener en
cuenta para efectuar la división es si las construcciones del lenguaje son de naturaleza recursiva o no.
Las construcciones lexicográficas no necesitan recursividad mientras que las construcciones sintácticas
si suelen hacerlo.
Las gramáticas libres de contexto, que estudiaremos más adelante, constituyen una
formalización de las reglas recursivas y se suelen utilizar para guiar el análisis sintáctico. Por el
contrario, para guiar el análisis léxico se suelen utilizar expresiones regulares (o definiciones
regulares), así como autómatas finitos que no necesitan recursividad.
Por ejemplo, para reconocer los identificadores no es necesaria la recursividad ya que suelen
ser cadenas de letras y dígitos comenzando por una letra. Para reconocer un identificador podemos
rastrear la cadena del programa fuente hasta que encontremos un carácter que no es ni letra ni
dígito y entonces agrupar todas las letras y dígitos anteriores para formar el token de un
identificador. Estos caracteres agrupados se registran en una tabla, llamada la tabla de símbolos, y se
borran de la cadena de entrada para que pueda continuar el proceso del token siguiente.
13
paréntesis de una expresión, o los BEGIN…END de sentencias, sin manejar algún tipo de estructura
anidada o jerárquica.
El anterior árbol de análisis (árbol de sintaxis concreta) nos muestra la estructura sintáctica de
la entrada, pero internamente es más corriente el representar dicha estructura mediante un árbol
sintáctico. Un árbol sintáctico (también llamado árbol de sintaxis abstracta) es una representación
compacta del árbol de análisis (árbol de sintaxis concreta) en el que los operadores aparecen en los
nodos y los operandos son las ramas hijas del nodo. Como ejemplo, el árbol sintáctico para el árbol
de análisis anterior sería el de la figura siguiente.
Un árbol de análisis (árbol de sintaxis concreta) representa los pasos realizados por el
analizador sintáctico para descubrir cuál es la estructura sintáctica de la entrada pero no suele ser
ninguna estructura real de datos (creada mediante punteros) en memoria interna. Por ejemplo, en los
analizadores descendentes recursivos, que realizaremos más adelante, cada nodo del árbol de
análisis representa una llamada a una función.
Por el contrario, el árbol sintáctico (árbol de sintaxis abstracta) sí que suele ser una estructura
de datos real creada en memoria interna mediante punteros y que representa la estructura sintáctica
de la entrada pero de una manera simplificada.
14
puede que sean permitidas algunas conversiones de operandos (cuando se aplica un operador,
como la suma y el producto, a un entero y un real el traductor tiene que convertir el entero a real).
15
Generador de código intermedio.
Después de los análisis léxico y sintáctico, algunos traductores generan una representación
interna explícita del programa fuente. Podemos pensar en dicha representación intermedia como un
programa para un ordenador abstracto. Esta representación interna debe tener dos propiedades
importantes: Debe ser fácil de producir y fácil de traducir al lenguaje objeto.
En este momento, la representación del programa en forma de árbol sintáctico (de sintaxis
abstracta) da paso a otra representación más cercana al lenguaje máquina, aunque aún no es el
lenguaje máquina.
El código de tres direcciones consta de una secuencia de instrucciones cada una de las
cuales tiene, como máximo tres operandos y un solo operador binario. Los operandos son los dos
datos a operar y la dirección donde almacenar el resultado.
Para generar estas instrucciones, el traductor debe decidir el orden en que se deben realizar
las operaciones (en el programa anterior primero se realiza la multiplicación y a continuación la
suma). Además el traductor debe generar almacenamiento temporal para los resultados intermedios.
Algunas de las instrucciones TAC (de tres direcciones) tienen menos de tres operandos. Por ejemplo
la primera y la última del programa mostrado.
Esta fase intenta mejorar el código intermedio para que se ejecute más rápido y/o ocupe
menos espacio. Algunas de las optimizaciones son triviales, como muestra el esquema, en que el
traductor deduce la conversión de entero a real del número 23 puede hacerse una sola vez en
16
tiempo de compilación, y así eliminar la operación itof que se realizaría en tiempo de ejecución.
También, es evidente que sobra alguna variable temporal.
Generador de código.
Una función esencial del traductor consiste en registrar los identificadores utilizados en el
programa fuente y recoger información sobre diferentes atributos de cada identificador. Estos
atributos pueden proporcionar información sobre el almacenamiento reservado para el identificador;
su tipo; su ámbito de definición (el trozo de programa donde es efectiva la definición); en el caso de
nombres de procedimiento datos como el número y el tipo de los parámetros; el tipo del resultado
(en caso de que hubiese alguno); el método de paso de parámetros (por valor, referencia, etc.).
17
La tabla de símbolos es una estructura de datos que contiene un registro por cada
identificador, con campos para cada uno de sus atributos. Es importante que esta estructura de datos
nos permita encontrar rápidamente el registro de cada identificador, y almacenar o consultar los
datos del registro.
Puede que sea el analizador léxico el encargado de incluir un nuevo registro en la tabla de
símbolos cada vez que detecta un nuevo identificador. Sin embargo los atributos de los
identificadores no podrán ser determinados durante el análisis léxico.
Cuando el analizador léxico detecta los identificadores total, cantidad y energia aun
no ha visto la palabra REAL.
Son las fases siguientes las que incluyen la información de los atributos de los identificadores
en la tabla de símbolos y las que utilizan dicha información de diferentes formas.
Otro ejemplo sería que, durante el análisis sintáctico y generación de código intermedio,
necesitamos conocer el tipo de los identificadores para poder comprobar que el programa los utiliza
de forma permitida y para poder generar las operaciones necesarias. El generador de código es el
que suele entrar y utilizar la información sobre las direcciones de memoria asignadas como
almacenamiento a cada identificador.
Cada fase puede detectar sus propios errores pero, después de detectar el error, cada fase
debe manejarlo de alguna forma para que pueda proseguir la compilación y permitir detectar más
errores en el programa fuente. Un traductor que terminase su ejecución, cada vez que detectara un
error, no sería muy útil.
El analizador léxico puede detectar errores cuando los caracteres de entrada no forman
ningún token conocido. El analizador sintáctico detecta errores cuando los tokens violan las reglas
gramaticales (la sintaxis). Durante el análisis semántico se detectan las sentencias que son
sintácticamente correctas pero no tienen significado (por ejemplo, cuando intentamos sumar dos
identificadores, uno de los cuales es el nombre de una tabla y el otro el nombre de un
procedimiento).
18
De ambas fases, la síntesis es la que necesita unas técnicas más especializadas. Durante el
análisis, se suele construir una representación interna jerárquica del programa que refleja su
estructura. Estas representaciones jerárquicas se llaman árboles, y uno de los árboles más utilizados
es el árbol sintáctico que ya hemos comentado anteriormente (también llamado árbol de sintaxis
abstracta) en el cada nodo representa una operación mientras que las hojas representan los
operandos.
Intérpretes.
En vez de producir como resultado un programa objeto, los intérpretes analizan el programa
fuente y ejecutan las operaciones mencionadas. Por ejemplo, para una sentencia de asignación, el
intérprete podría construir un árbol sintáctico como el mostrado anteriormente para, a continuación,
realizar las operaciones implicadas conforme recorriera el árbol. Al mirar la raíz descubriría que se
trata de una asignación, de forma que llamaría a una subrutina para evaluar la rama derecha y
almacenar el resultado en la dirección asociada con el identificador de la rama izquierda. La rutina
para evaluar la rama derecha, encontraría que tiene que realizar el producto de dos expresiones, asía
que se llamaría recursivamente para calcular el valor de “cantidad ↑ 2” y a continuación
multiplicarlo con el valor almacenado en la dirección asociada con la variable llamada total.
A menudo se utiliza a los intérpretes para ejecutar lenguajes de comandos ya que estos
suelen llamar a rutinas complejas como pueden ser ejecutar un editor o un traductor. Por la misma
razón los lenguajes de muy alto nivel suelen ser interpretados ya que existen muchas características
de los datos, como el tamaño de los arrays, que no se conocen en tiempo de compilación.
A menudo las fases se agrupan en una frontal (front end) y una final (back end). La frontal
consta de aquellas fases que dependen principalmente del lenguaje fuente y que son bastante
independientes de la máquina objeto. Aquí se incluye el análisis léxico, el sintáctico, la creación de la
tabla de símbolos, el análisis semántico y la generación de código intermedio. La parte frontal
también puede realizar una cierta optimización de código. Por supuesto se incluye el manejo de
errores de cada una de estas fases.
19
La parte final incluye las fases que dependen de la máquina objeto y que no dependen del
lenguaje fuente sino del lenguaje intermedio. Aquí encontramos trozos de la optimización de código
y toda la generación de código, junto con el manejo de errores y de la tabla de símbolos necesarios.
Es muy corriente el tomar el front end de un traductor y cambiarle el back end para producir
otro traductor del mismo lenguaje fuente pero para otra máquina distinta. Si el back end fue
diseñado cuidadosamente puede que ni siquiera sea necesario modificarlo demasiado.
Varias fases de la compilación se suelen implementar en un único paso que consiste en una
lectura del archivo de entrada y en una escritura de un archivo de salida. En la práctica hay una gran
variación en la organización de los traductores en pasos, así que preferimos el concepto de fase.
Es muy común el agrupar las fases en pasos y, durante cada paso, turnar la actividad de las
fases. Por ejemplo, podríamos agrupar en un solo paso al análisis léxico, sintáctico, semántico, y la
generación de código intermedio. Es decir, el analizador sintáctico iría llamando al analizador léxico
para que le suministre los tokens de uno en uno conforme los fuese necesitando. Una vez que el
analizador sintáctico hubiese descubierto una cierta estructura sintáctica completa, llamaría al
generador de código intermedio para que realizara el análisis semántico y generara un trocito de
código.
Debido a que la lectura de un archivo y la escritura de otro es una tarea que consume
bastante tiempo, es conveniente el utilizar el menor número posible de pasos. Esto tiene el
inconveniente de que podemos estar forzados a mantener todo el programa en memoria, ya que
una fase puede necesitar la información en otro orden del que la va produciendo otra fase. La
representación interna del programa puede necesitar mucho más espacio que el programa fuente o
que el programa objeto, así que el espacio es un asunto importante.
A veces no representa problemas el agrupar varias fases en un mismo paso. Por ejemplo, ya
dijimos que la interfaz entre el analizador léxico y el sintáctico puede estar reducida a un único token.
Pero por otro lado es muy difícil el generar código máquina hasta que no se ha producido todo el
código intermedio. Por ejemplo, lenguajes como Javascript o Perl permiten utilizar variables antes de
declararlas, así que podemos generar el código si no sabemos el tipo de los datos. La mayoría de los
lenguajes permiten saltar (GOTO) a posiciones más avanzadas en el código. Cuando se encuentra
uno de estos GOTO no conocemos aún la dirección final del salto hasta que no hayamos analizado el
programa entre el salto y la etiqueta generado por el código para él.
En algunos casos es posible reservarle cierto espacio en blanco, para la información que
falta, y rellenar dicho espacio cuando dispongamos de la información.
Por ejemplo, en los párrafos anteriores hemos discutido un ensamblador de dos pasos. En el
primero descubría los identificadores que representaban direcciones de memoria y deducía dicha
dirección. A continuación el segundo paso sustituía los identificadores por sus direcciones. Esto
también se puede realizar en un único paso si al encontrar una sentencia de la forma:
GOTO destino
20
Generamos una instrucción con el código para la operación pero con un espacio reservado
en blanco para la dirección. Simultáneamente, a la entrada en la tabla de símbolos de la etiqueta en
cuestión le vamos colgando una lista con todas las instrucciones que hacen referencia a dicha
etiqueta. Por último, cuando encontramos una instrucción del tipo:
Podemos determinar el valor de la etiqueta “destino”, que será el valor de la dirección actual,
y rellenar todos los blancos dejados anteriormente recorriendo la lista asociada con la etiqueta. Esta
técnica se conoce como backpatch, es decir, parchear retrocediendo, y es fácil de implementar
siempre que se puedan mantener en memoria todas las instrucciones hasta que se determine la
dirección de destino. Esto suele ocurrir en los ensambladores en los que el tamaño del código
intermedio es equivalente al tamaño del código fuente y al del código objeto. Por lo tanto suele ser
posible parchear retrocediendo por todo el programa. En otros traductores, en los que el código
intermedio ocupe mucho espacio, necesitaremos controlar la distancia a la que retrocedemos para
parchear.
21