Descargar como pdf o txt
Descargar como pdf o txt
Está en la página 1de 21

COMPILADORES Y PREPROCESADORES DE LENGUAJES

INTRODUCCIÓN Y DEFINICIONES

CONCEPTO DE TRADUCTOR

Un traductor es un programa que lee un programa fuente escrito en lenguaje fuente y


produce como resultado otro programa, con el mismo significado (misma semántica) que el fuente,
llamado programa objeto y escrito en lenguaje objeto. A su vez, el traductor está escrito en lenguaje
de implementación.

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.

- En un traductor es importante distinguir entre:


o Tiempo de construcción del compilador. Se refiere al momento en el que construimos el
traductor (o compilador). Se procesan los fuentes de nuestro traductor para producir el
traductor ejecutable.
o Tiempo de compilación. Se refiere al momento en que el usuario utiliza nuestro traductor
para traducir sus propios programas.
o Tiempo de ejecución. En este momento el usuario ejecuta sus propios programas, ya
procesados por nuestro traductor, para producir sus resultados.

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.

Cuando el lenguaje fuente es el lenguaje Ensamblador, y el objeto es el lenguaje máquina, al


traductor se le denomina Ensamblador. Aunque solemos utilizar muy a menudo, los términos
traductor y compilador como sinónimos, en realidad lo que se conoce propiamente como
compilador es un traductor en el que el lenguaje fuente es alguno de alto nivel mientras que el
objeto es el Ensamblador o el lenguaje máquina.

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.


 
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.

NOTAS HISTÓRICAS SOBRE LOS TRADUCTORES

A continuación presentamos una pequeña introducción histórica a los compiladores.


Creemos que puede servir para situar el tema dentro de contexto y para destacar la situación actual y
el papel que han jugado en el desarrollo de la informática.

FORTRAN Y LOS ORÍGENES DE LOS COMPILADORES

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”.

El diseño inicial externo del compilador de FORTRAN estuvo concluido en Noviembre de


1954, se escribieron algunos documentos y se impartieron algunas charlas explicándolo a los posibles
usuarios de los sistemas 704 de IBM. Todo esto encontró la indiferencia y el escepticismo usual del
clero informático con algunas excepciones notables que permitieron unirse al grupo a otros
miembros. Los directivos de IBM fueron también indulgentes con un trabajo que continuamente
precisaba más tiempo, más ayuda y sólo presentaba líneas de investigación sin continuidad posible.
Pero por lo general se topó con una mezcla de indiferencia y socarronería que no terminó hasta las


 
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.

Lo que se entiende en la actualidad por compilador es el resultado de la convergencia


afortunada de varias tradiciones anteriores. Entre ellas podríamos mencionar a los lenguajes para
descripción de algoritmos, los sistemas de ayuda del estilo de los ensambladores primitivos,
mantenedores de librerías de rutinas etc. y las técnicas de programación.

LENGUAJES PARA DESCRIBIR ALGORITMOS:

Desde la aparición de los ordenadores, se habían desarrollado lenguajes para la descripción


de algoritmos. De entre los primeros intentos podemos mencionar al español Leonardo Torres y
Quevedo que en 1914 utilizaba el lenguaje natural para describir los pasos de un programa corto
para su hipotético autómata. Turing utilizó una descripción más precisa en la forma de complicadas
tablas de transición entre estados de su famosa máquina teórica, y por último el lambda cálculo de
Alonzo Church con un enfoque completamente diferente.

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


 
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 parte contemporáneo con los trabajos de Goldstein y Von Neumann es el trabajo de


Haskell B. Curry que a raíz de la experiencia adquirida en 1946 al escribir un programa bastante
complejo para el ENIAC, propuso una notación más compacta que los diagramas de flujo. Así
describía su loable intención al diseñar este lenguaje: “El primer paso al planificar un programa
consiste en analizar el cómputo en varias partes principales, llamadas divisiones, tales que el
programa pueda ser sintetizado en ellas. Dichas partes principales, o al menos algunas de ellas,
deben ser tales que constituyan cálculos independientes por derecho propio o bien sean
modificaciones de dichos cálculos”. No se puede dudar que Curry estaría de acuerdo con la
programación estructurada actual. En la práctica su propuesta no tuvo mucho éxito porque la
manera en que factorizaba un programa no resultaba muy natural y la notación era un tanto
excéntrica.

En realidad el mayor interés de estos primeros trabajos de Curry no consiste en el lenguaje


de programación sino en el algoritmo que diseñó para convertir parte de él en lenguaje máquina.
Proporcionando una descripción recursiva de un procedimiento para convertir una expresión
aritmética, bastante general, en código de una dirección para un ordenador. Por lo tanto fue la
primera persona en describir la fase de generación de código de un ordenador (no describía el
análisis sintáctico partiendo de la suposición de que la fórmula podría ser analizada adecuadamente).

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


 
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.

EL ORDENADOR COMO PROCESADOR DE SÍMBOLOS:

En este tiempo se estaban desarrollando otras herramientas para ayuda a la programación


pero el definitivo salto cuántico necesitaba de un cambio en la mentalidad y en la forma de entender
los ordenadores. Estos habían comenzado como calculadoras y se necesitaba un cambio de actitud
para admitir al ordenador como un manipulador de símbolos y no solamente como un machacador
de números, y este cambio fue difícil de conseguir. Pero dejemos que sea R. W. Hamming quien nos
lo cuente:

- "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”.


 
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.

Muchos programadores de los años 50 comenzaron a considerarse como miembros de una


clase sacerdotal guardiana de ciertas habilidades y misterios demasiado complejos para los
mortales ordinarios. Este sentimiento se mencionaba en el artículo de J. H. Brown y John E. Carr
III, en el simposium de la ONR de 1954: …- Muchos usuarios profesionales de ordenadores se
oponen fuertemente a la utilización de números decimales […] para este grupo, no debería
permitirse al no iniciado la generación de instrucciones máquina. – Esta actitud enfrió el
desarrollo de ayudas sofisticadas para la programación. Los sacerdotes deseaban y obtenían
ayudas mecánicas sencillas para las tareas sacerdotales que los atosigaban, pero consideraban
con hostilidad otros planes más ambiciosos que hiciese accesible la programación a una
población mayor. Para ellos era, evidentemente, un sueño arrogante y demencial el imaginar
que ningún proceso mecánico pudiese realizar los misteriosos desafíos de imaginación
necesarios para escribir un programa eficiente. Solamente los sacerdotes podrían hacerlo. Por lo
tanto se oponían, invariablemente, a ningún tipo de cambio revolucionario que quisiese hacer a
la programación tan simple que cualquiera pudiera realizarla”.

Como se ve, los compiladores fueron un invento revolucionario que democratizó la


utilización de ordenadores (por aquel entonces se hablaba de programación automática) y que
fueron recibidos con cierta hostilidad por un porcentaje alto del estamento informático de aquel
tiempo.

Consideraban que “programar” consistía en escribir código máquina y buscaban un sistema


que, a partir de una descripción del problema, fuese capaz, de forma automática, de generar código
para su resolución. Es una forma de contemplar el problema de la traducción de un lenguaje de alto
nivel a otro de bajo nivel. Hoy en día ha cambiado el concepto de en qué consiste programar un
ordenador. También es notable el observar que el lenguaje FORTRAN, que hoy en día consideramos
imperativo y de no muy alto nivel, fue un primer intento en la dirección de los lenguajes declarativos
que buscan describir el problema y no el mecanismo de solución. Podemos concluir que la
“declaratividad” de los lenguajes de programación es una cuestión de grado que siempre ha estado
presente en la intención de los diseñadores de lenguajes.


 
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.


 
Preprocesadores.

Los preprocesadores proporcionan la entrada para los traductores y pueden realizar las
siguientes funciones:

- Proceso de macros: los macros son abreviaturas para instrucciones o conjuntos de


instrucciones. A menudo la definición de un macro permite parámetros formales. Cuando se
utiliza un macro se suministran parámetros concretos que sustituyen a los formales que
aparecían en la definición y los valores actuales se sustituye el macro por el resultado.
- Inclusión de archivos: puede permitirse la inclusión de otros archivos. Por ejemplo el
preprocesador del lenguaje C cuando encuentra la sentencia #include(stdio.h)
sustituye dicha sentencia por el contenido del archivo (stdio.h).
- Preprocesadores racionales y extensiones a los lenguajes: estos preprocesadores enriquecen
lenguajes anticuados con nuevas estructuras de flujo de control y manejo de datos más
actuales. Por ejemplo, el lenguaje C++ se distribuía en principio como un preprocesador
para el compilador de C, que simulaba las extensiones a objeto del lenguaje C++ y las
convertía en construcciones de C.

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.

Cargadores y enlazadores (linker).

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.


 
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).

El código máquina reubicable de un módulo debe retener la información de la tabla de


símbolos para cada uno de los datos o direcciones de salto que se referencian externamente. Puede
que sea necesario el preceder el código reubicable de toda la tabla de símbolos del ensamblador.

FASES DE UN TRADUCTOR Y CAMBIO EN LA REPRESENTACIÓN

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 del programa fuente.

Podemos describir tres tipos de análisis:

- 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.


 
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

energia = total = cantidad + 23

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:

Tokens Descripción informal de los Lexemas


patrones
Entero identificador Secuencia de dígitos decimales 1234, 4453, 0
Secuencia de letras y dígitos cantidad, total, energia
comenzando por una letra
Operador Alguno de entre “+”, “*”, “=” +, *, =
Las letras “f”, “o”, “r” for

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.

En el esquema, que mostramos a continuación, utilizamos tokens con subíndices para


enfatizar que la representación interna es diferente de la cadena de caracteres. Los subíndices
representan los valores léxicos asociados con cada token.

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:

Tabla de símbolos: energia …


total …
cantidad …

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:

1. Una expresión, por sí sola, constituye una sentencia.


2. Si expr es una expresión y sent es una sentencia, entonces:
 WHILE (expr) sent
 IF (expr) sent ELSE sent

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.

Generalmente este tipo de rastreo lineal no es lo suficientemente poderoso como para


analizar expresiones o sentencias. Por ejemplo, no podemos emparejar adecuadamente los

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.

Análisis semántico (o de restricciones contextuales).

En esta fase se chequea el programa fuente buscando errores semánticos y recopilando


información sobre los tipos de los datos para la fase de generación de código posterior. Se utiliza la
estructura jerárquica creada por el analizador sintáctico para identificar los operadores y operandos
de las expresiones.

Un componente importante del análisis semántico lo constituye el chequeo de tipos de datos


donde se comprueba que los operandos de cada operador son de un tipo permitido por las
especificaciones del lenguaje. Por ejemplo: muchos lenguajes no permiten la utilización de un real
como índice de una tabla, los dos operandos de una suma deben ser del mismo tipo, sin embargo

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).

Por ejemplo, en un ordenador la forma interna de almacenar un entero y un real es


diferente, incluso aunque el entero y el real parezcan tener el mismo valor. Suponga que en la
sentencia de asignación: “energia = total = cantidad + 23”, los identificadores energia,
total y cantidad han sido declarados reales (flotantes), mientras que el número 23, escrito sin
punto decimal, se entiende que es un entero. El análisis semántico revela que el operador suma (+)
se va a aplicar a un real (cantidad) y a un entero (23), así que debe convertir el entero en real. Esto
se muestra en los árboles sintácticos siguientes, donde se ha creado un nuevo nodo para el operador
itof que convierte un entero (integer) en (to) real (float).

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.

La representación intermedia puede tener muchas formas posibles. En el esquema hemos


mostrado una forma concreta denominada código de tres direcciones (CTD en español, o TAC en
inglés, Three Address Code), que es equivalente al lenguaje ensamblador para un ordenador en el
que cada dirección de memoria puede actuar como un registro.

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.

Optimizador de código intermedio.

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.

Hay mucha diferencia en la cantidad de optimización de código que realizan diferentes


traductores. Los llamados traductores optimizadores pasan una fracción muy significativa del tiempo
de ejecución en esta fase. Sin embargo, existen optimizaciones sencillas que mejoran bastante el
tiempo de ejecución del programa objeto sin ralentizar demasiado la compilación.

Generador de código.

La fase final de un traductor consiste en la generación de código, produciendo generalmente


código máquina reubicable o lenguaje ensamblador.

Primero se seleccionan posiciones de memoria concreta para las variables utilizadas en el


programa. A continuación se traducen las instrucciones intermedias a instrucciones máquina. Un
aspecto crucial es la asignación de variables a registros. Por ejemplo, en nuestro programa no hemos
utilizado ninguna variable temp1 si no que hemos utilizado, directamente, los registros de la unidad
de punto flotante. Sólo existen 8 registros en dicha unidad de punto flotante, de forma que si
hubiésemos necesitado muchas variables temporales no bastarían con los registros (si todas las
variables deben estar vivas al mismo tiempo).

No hemos mencionado un punto importante que es la asignación de direcciones de los


identificadores. La organización del almacenamiento en memoria en tiempo de ejecución depende
del lenguaje que se esté compilando. En nuestro ejemplo, generado por un programa en C, las
variables son estáticas.

Manejo de la tabla de símbolos.

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.

Por ejemplo, en la siguiente sentencia de Pascal:

VAR total, cantidad, energia : REAL;

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.

Detección de errores y emisión de mensajes de error.

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).

DIVISIÓN EN LAS FASES DE ANÁLISIS Y DE SÍNTESIS

A veces se divide la compilación en dos fases: el análisis y la síntesis. Durante el análisis se


estudia el programa fuente partiéndolo en sus trozos constituyentes intentando determinar el
significado de dicho programa y construyendo una representación intermedia. Durante la síntesis se
construye el programa objeto a partir de la representación intermedia.

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.

COMPILADORES versus INTÉRPRETES

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.

Intérpretes de consultas a una base de datos.

Se trata de un tipo especial de intérprete en el que la entrada constituye predicados con


conectores relacionales lógicos y la salida las órdenes de consulta a una base de datos que satisfacen
las relaciones mencionadas.

AGRUPAMIENTO DE LAS FASES

La discusión anterior sobre las fases se refería a la organización lógica de un traductor. No


obstante, a la hora de implementarlas, puede que más de una fase se agrupen juntas.

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.

También es tentador el compilar varios lenguajes fuente en el mismo lenguaje intermedio,


utilizando una única parte final con varias partes frontales diferentes. Así se obtendrían varios
traductores de diferentes lenguajes fuentes para el mismo ordenador. Esto no ha tenido demasiado
éxito debido a que los distintos lenguajes necesitan distinto manejo de memoria y diferente
organización interna en tiempo de ejecución.

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:

destino: MOV corriente, R1

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.

HERRAMIENTAS PARA LA CONSTRUCCIÓN DE TRADUCTORES

El escritor de un traductor puede utilizar provechosamente, al igual que otros


programadores, muchas herramientas de software como: depuradores, programas make, editores,
etc. pero además se han desarrollado otras herramientas más especializadas para la ayuda en la
implementación de las diferentes fases de un traductor.

A estas herramientas se las denomina compiladores de compiladores, generadores de


compiladores, sistemas para la escritura de traductores etc. Suelen estar orientados hacia un cierto
modelo particular de lenguajes, y son más útiles para desarrollar traductores de lenguajes similares a
los del modelo. Existen varias herramientas desarrolladas para el diseño de componentes específicos
de traductores, acostumbran a tener su propio lenguaje para describir el componente que se
requiere y, a veces, utilizan algoritmos sofisticados difíciles de programar a mano. Las herramientas
más útiles son las que ocultan los detalles de los algoritmos y producen componentes que puedan
ser integrados fácilmente con el resto del traductor, por ejemplo:

1. Generadores de analizadores léxicos (scanner generators). Generan los analizadores a partir


de descripciones basadas en expresiones regulares. La implementación resultante suele ser
un autómata finito.
2. Generadores de analizadores sintácticos (parser generators). Producen analizadores
sintácticos a partir de una descripción cercana a las gramáticas libres de contexto. En los
primeros traductores la escritura del analizador sintáctico consumía la mayor parte del
tiempo y del esfuerzo intelectual, sin embargo ahora se considera que esta fase es una de las
más sencillas de implementar.
3. Máquinas traductoras dirigidas por sintaxis. Producen conjuntos de rutinas que recorren el
árbol sintáctico generando código intermedio. La idea es que a cada nodo del árbol se le
asocia una traducción, y cada traducción está definida en función del nodo actual y de las
traducciones de los nodos vecinos.

21 
 

También podría gustarte