2. ELEMENTOS DEL CURSO
Programa del Curso..... PDF
Datos del Profesor …. RZC
Contenido del Curso…. Diaps
Proyectos …. Archs
Software y Herramientas
4. 1.1 ¿Qué es y que estudia la P. de S. ?
Son los programas que residen en un
sistema de computación. Su función es
proporcionar al usuario o programador una
interfase mas eficiente y practica con relación
al hardware de la maquina.
La P. de S. estudia como están
implementados cada uno de los programas
de un Sistema (ver notas).
5. 1.2 Herramientas desarrolladas
con la P de S
Ejemplos:
Compiladores (javac)
Ensambladores (Masm)
Interpretes (Visual Basic)
Ligadores (Link)
Cargadores
Sistema Operativo (Windows)
Utilerías de Sistemas (Debugger)
6. 1.3 Lenguajes
Naturales (Traductores de Ingles-Español,
Ingles-Ruso, etc)
Artificiales (Compiladores de LP como Java,
C++, Ada, etc.)
7. 1.4 Traductor y su Estructura
Ensambladores …. (notas)
Compiladores……. (notas)
Intérpretes……...... (notas)
8. 1.5 Generador de Código para Compiladores
(Compilador de Compiladores)
Definición: Un compilador de compiladores o generador de
Parsers es una utilería para generar el código fuente de un
parser, intérprete o compilador a partir de una descripción de
lenguaje anotado en la forma de gramática (usualmente BNF)
mas código que es asociado con cada una de las reglas de la
gramática que debe ser ejecutada cuándo esas reglas sean
aplicadas por el parser. Esas piezas de código son algunas
veces llamadas rutinas de acciones semánticas ya que ellas
definen la semántica de las estructura sintáctica y que es
analizada por el parser. Dependiendo del tipo de parser que será
generado, las rutinas pueden construir un árbol de parsing (o
AST) o generar código ejecutable directamente (notas).
10. Visión del Problema.
Consideraciones Preliminares.
Objetivos y filosofías del diseño de los
lenguajes de programación.
Diseño detallado.
Caso de estudio.
12. 3.1 Introducción a los Autómatas Finitos
y Expresiones Regulares
(ver apuntes de Lenguajes y Autómatas).
13. 3.2 Analizador de Léxico.
Descompone la entrada en palabras individuales llamadas “tokens”. El
analizador de léxico (scanner) toma una cadena de caracteres que
forman la entrada y produce una cadena de nombres o identificadores,
palabras claves o reservadas (PC) signos o marcas de puntuación,
operadores aritméticos y lógicos, constantes (números, cadenas, etc.) y
otros. También el scanner tiene como función desechar espacios en
blanco y comentarios entre los tokens, otra función podría ser crear la
tabla de símbolos.
Ejemplo:
TIPO EJEMPLOS
ID foo n14 last
NUM 73 0 00 515
REAL 66.1 .5 10. 1e67
IF if
COMMA ,
NOTEQ !=
LPAREN (
14. Analizador de Léxico (cont.)
Tokens de puntuación como IF, VOID y
RETURN son llamadas palabras reservadas
y en la mayoría de los lenguajes no pueden
usarse como identificadores.
15. 3.3 Manejo de “Buffers”
El analizador de léxico (scanner) y el analizador de
sintáxis (parser) forman un duo “productor-
consumidor”. El scanner produce tokens y el parser
los consume.
La implementación de la lectura de los caracteres
de la entrada (disco) es usualmente hecha por un
buffer (memoria) que contendrá una buena parte de
la entrada, desde donde el scanner irá formando los
tokens. Esto agiliza la entrada que de otra forma
leería carácter por carácter desde el disco.
16. 3.4 Creación de la Tabla de
Símbolos.
Checar notas
La tabla de símbolos es una estructura de datos
muy importante en casi todo el proceso de
compilación. En ella se guarda durante las primeras
fases de compilación los nombres de los
identificadores (símbolos) usados en el programa
fuente, además de los atributos de cada uno de
estos identificadores. Estos identificadores y
símbolos junto con sus atributos serán usados
posteriormente para realizar funciones como el
chequeo de tipos, la asignación de memoria,
generación de código objeto etc.
17. Ejemplo
Ejemplo.-
Programa X1;
Var X, Y: Integer;
Z: Real;
Arreglo: Array [1…100] of int
Procedure compara (a, b: Integer)
Var n, m: integer;
Begin
----
----
End
Begin
----
----
End
18. 3.5 Manejo de Errores de Léxico
Erroresposibles detectados en el análisis de
léxico son:
Patrones de tokens que no coincidan con algún
patrón válido. Por ejemplo el token #### sería
inválido en algunos L.P.
Caracteres inválidos para el alfabeto del el
lenguaje.
Longitud de ciertos tokens demasiado larga.
19. 3.6 Generadores de Código
Léxico
La construcción de un scanner puede
automatizarse por medio de un generador de
analizadores de léxico.
El primer programa de este tipo que se hizo
popular fue Lex (Lesk, 1975) que generaba
un scanner en lenguaje C.
Hoy en día existen muchos programas de
este tipo: Flex, Zlex, YooLex, JavaCC,
SableCC, etc.
20. Ejemplo: Javacc
Javacc (Java Compiler Compiler) es un generador
de scanners y parsers (https://javacc.dev.java.net/).
Toma como entrada especificaciones de léxico
(expresiones regulares) y sintáxis (gramática de
contexto libre) y produce como salida un analizador
de léxico y un parser recursivo decendente.
Se puede usar como pre-procesador de Javacc, a
jjtree y a JTB para la construcción del árbol
sintáctico.
21. (cont.)
USO DE JAVACC
Miparser.jj javacc Miparser.jj Miparser.java
ParseException.java
TokenMgrError.java
otros archivos java
javac Miparser.java
Miparser.class java Miparser <inputfile mensajes
22. (cont.)
EJEMPLO DE ESPECIFICACION PARA GENERAR UN SCANNER
PARSER_BEGIN(PS1)
class PS1 {}
PARSER_END(PS1)
/* Para la expresión regular de la derecha lo de la izquierda será retornado */
TOKEN: {
<IF: "if">
|<#DIGIT: ["0"-"9"]>
|<ID: ["a"-"z"] (["a"-"z"]|<DIGIT>)*>
|<NUM: (<DIGIT>)+>
|<REAL: ((<DIGIT>)+ "." (<DIGIT>)*) |
((<DIGIT>)* "." (<DIGIT>)+)>
}
SKIP: {
<"--" (["a" - "z"])* ("n" | "r" | "rn")>
|" "
|"t"
|"n"
|"r"
}
void Start():
{}
{ (<IF> | <ID> | <NUM> | <REAL>)* }
24. 4.1 Introducción a las gramáticas
Libres de Contexto y Árboles de
derivación
El tipo de gramáticas usadas en los LP son llamadas gramáticas de contexto libre, las cuáles,
junto con árboles de derivación, fueron estudiadas en el curso de Lenguajes y Autómatas.
Un ejemplo de una gramática para un LP simple es la siguiente:
1) SS;S 2) Sid := E 3) Sprint (L)
4) E id 5) E num 6) E E + E 7) E(S,E)
8) L E 9) L L , E
(ver ejemplo de derivaciones y árboles de parsing)
A continuación veremos un ejemplo de una gramática para Java en formato BNF (liga).
25. (cont.)
Gramática Ambigua. Una gramática es ambigua si
puede derivar una oración (cadena) con dos
diferentes árboles de parsing. La sig. Gramática es
ambigüa:
Eid Enum EE*E EE/E
EE+E EE-E E(E)
ya que tiene dos árboles de parsing para la misma
oración (árboles para id:=id+id+id con primera
gramática y árboles para 1-2-3 con segunda en sig
“slice”).
26. (cont.)
S
S
Id := E
Id := E
E + E
E + E
E + E id
id E + E
Id id
id id
27. (cont.)
E
E
E - E
E - E
E - E 3
1 E - E
1 2
2 3
28. 4.2 Diagramas de Sintaxis
Un método alternativo al BNF para desplegar
las producciones de ciertas gramáticas es el
diagrama de sintaxis. Ésta es una imagen
de la producciones que permite al usuario ver
las sustituciones en forma dinámica, es decir,
verlas como un movimiento a través del
diagrama.
Ejemplo en Java (liga)
29. 4.3 Precedencia de Operadores
En los dos ejemplos vistos en sección 4.1, los dos
árboles de parsing para 1-2-3 significaba diferentes
cosas: (1-2)-3=-4 versus 1-(2-3)=2. Similarmente,
(1+2)x3 no es lo mismo que 1+(2x3). Es por eso
que gramáticas ambiguas son problemáticas para
un compilador. Afortunadamente, una gramática
ambigua puede convertirse en una no ambigua. Por
ejemplo, si queremos encontrar una gramática no
ambigua para el lenguaje de la segunda gramática
de sección 4.1, lo podemos hacer por medio de dos
operaciones: primero, aplicar orden de precedencia
a los operadores y segundo aplicar asociatividad
por la izquierda. Con esto, tenemos la sig.
gramática:
30. (cont.)
SE$ nota: “$” es EOF- marker
EE+T EE-T ET
T-->T*F TT/F TF
Fid Fnum F(E)
Esta gramática acepta el mismo lenguaje que la
de sección 4.1 pero solo produce un árbol de
parsing por oración. Esta gramática no podría producir
árboles como los siguientes:
X ?U
+ ?Y ?V *
+ +
31. 4.4 Analizador Sintáctico
En esta fase se analiza la estructura de la frase del programa.
El parser es el programa que funciona como núcleo del compilador.
Alrededor del parser funcionan los demás programas como el scanner, el
analizador semántico y el generador de código intermedio. De hecho se
podría decir que el parser comienza el proceso de compilación y su primer
tarea es pedir al escáner que envíe los tokens necesarios para llevar a
cabo el análisis sintáctico, del estatuto, expresión o declaración dentro de
un programa.
También el parser llama rutinas del análisis semántico para revisar si el
significado del programa es el correcto.
Por ultimo el parser genera código intermedio para los estatutos y
expresiones si no se encontraron errores en ellos.
32. (cont.)
Existen diferentes técnicas o métodos para realizar un análisis
sintáctico “Parsing”. Estas técnicas se dividen en dos tipos:
Descendentes
Ascendentes
Las técnicas descendentes realizan su análisis partiendo desde el
símbolo inicial de la gramática y realizando derivaciones hasta
llegar a producir las hojas o tokens.
Por otra parte las técnicas ascendentes realizan su análisis
partiendo de las hojas o tokens y mediante una serie de
operaciones llamadas reducciones terminan en la raíz o símbolo
inicial de la gramática.
Por lo general las técnicas descendentes son mas sencillas de
implementar que las ascendentes, pero por otra parte son menos
eficientes
33. 4.4.1 Analizador descendente (LL).
Parsing Predictivo
Algunas gramáticas son sencillas de analizarse sintácticamente
usando un algoritmo o método cuyo nombre es recursivo
descendente. En esta técnica cada producción de la gramática
se convierte en una cláusula de una función recursiva.
Un parser Predictivo es aquel que “ predice ” que camino tomar
al estar realizando el análisis sintáctico. Esto quiere decir que el
parser nunca regresara a tomar otra decisión ( back tracking )
para irse por otro camino, como sucede en las derivaciones.
Para poder contar con un parser predictivo la gramática debe de
tener ciertas características, entre ellas la mas importante es
que el primer símbolo de la parte derecha de cada producción le
proporcione suficiente información al parser para que este
escoja cual producción usar. Normalmente el primer símbolo
mencionado es un Terminal o token.
34. (cont.)
Esta técnica se utilizó o popularizó en los años 70 a partir del
primer compilador de pascal implementado con ella. A
continuación ilustraremos esto escribiendo un parser recursivo
descendente para la siguiente gramática:
S if E then S else S
S begin S L
S print E
L end
L ; S L
E num = num
Nuestro Parser tendría 3 métodos (uno por cada producción)
35. Programa del Parser
Final int if = 1, then = 2, else = 3, begin = 4, end = 5, print = 6, semi = 7,
num = 8, EQ = 9
int tok = get token ( );
void advance ( ) { tok = get token ( ); }
void eat ( int t) { if ( tok == 1) advance ( ); else error ( ); }
void S ( ) { switch ( tok ) {
case If: eat ( if ); E ( ); eat ( then ); S ( );
eat ( else ); S ( ); break;
case begin: eat ( begin ); S ( ); L ( ); break;
case print: eat ( print ); E ( ); break;
default: error;
}}
void L ( ) { switch ( tok ) {
case end: eat ( end ); break;
case semi: eat ( semi ); S ( ); L ( ); break;
default: error ( );
}}
void E ( ) { eat ( num ); eat ( EQ ); eat ( num ); }
36. (cont.)
Un parser predictivo que examina la entrada de izquierda a
derecha (left-to-right) en un paso y realiza su derivación por la
izquierda es llamado “Parser LL”.
Cuando el parser solo necesita “mirar” el siguiente token para
hacer su función (llokahead(1)), recibe el nombre de Parser
LL(1).
Un parser podría necesitar “mirar” K tokens por adelantado para
tomar desiciones. En este caso recibe el nombre de parser
LL(K).
Ejemplo: (parte de una gramática)
IF-STM if EXP then STM else STM
| if EXP then STM
37. Eliminación de Recursión por la izquierda
Suponer que queremos construír un Parser
predictivo para la gramática de sección 4.3.
SE$ EE+T EE-T ET
T-->T*F TT/F TF
Fid Fnum F(E)
Producciones como E E + T contienen recursión por la izquierda.
Parsers descendentes no pueden manejar recursión por la izquierda
en una gramática. Para eliminar este tipo de recursión utilizamos la
siguiente transformación:
38. (cont.)
En general, si tenemos producciones X X γ
y X α, donde α no comience con X
podemos aplicar la siguiente transformación:
X Xγ1 X α1X’
X Xγ2 X α2X’
X α1 X’ γ1X’
X α2 X’ γ2X’
X’ λ
39. (cont.)
Aplicando la transformación a la gramática anterior,
obtenemos la nueva equivalente gramática (sin
recursión por la izquierda):
S E$
E T E’ E’ + T E’ E’ - T E’ E’ λ
T F T’ T’ * F T’ T’ / F T’ T’ λ
F id F num F (E)
40. Factorización por la izquierda
Otro problema en una gramática ocurre cuando dos producciones
para el mismo no terminal comienza con el mismo símbolo. Por
ejemplo:
IF-STM if EXP then STM else STM
IF-STM if EXP then STM
En dicho caso podemos factorizar por la izquierda la gramática. El
resultado sería el sig:
IF-STM if EXP then STM X
X λ
X else IF-STM
las producciones anteriores facilitarán el trabajo del parser
predictivo.
41. 4.4.2 Analizador ascendente(LR y
LALR).
La debilidad de las técnicas descendentes LL(k) es que
deben predecir que producciones usar, después de
haber mirado los primeros k tokens de la cadena de
entrada.
Una técnica ascendente mas poderosa es la técnica
LR(K), la cual pospone la decisión hasta que ha visto
los tokens de la entrada correspondientes a la parte
derecha de la producción (además de k mas tokens
también).
LR(K) quiere decir parser de izquierda a derecha,
derivación por la derecha y “lookahead(k)”. Esta
técnica fue introducida por primera vez en 1965 por
Knuth.
42. (cont.)
Un Parser LR(K) está compuesto de:
La cadena de entrada
Una pila
Una tabla de Parsing LR
El parser obtiene los tokens de la entrada y
dependiendo de el token actual y de lo que
está en el tope de la pila, ejecuta una de dos
acciones (shift-reduce) con la ayuda de la
tabla de parsing
43. Algoritmo de Parsing LR (aho,Sethi y Ullman)
Tomar el primer token de w$ /* w es la cadena */
Repeat forever begin
Sea s el estado en el tope de la pila y a el token actual;
if acción[s,a] = shift s’ then begin
push a’ primero y sedpués s’ al tope de la pila;
obten el siguiente token de la cadena de entrada
else if acción[s,a] = reduce A->B then begin
pop 2*|B| símbolos fuera de la pila;
Sea s’ ahora el estado en el tope de la pila;
Push A y después goto[s’,A] al tope de la pila;
Imprime la producción A->B
end
else if acción[s,a] = accept then
return
else error()
end
46. Parsing LALR
La técnica de parsing LALR (LookAhead-LR) evita el uso
de tablas muy grandes, como las manejadas en la
técnica de parsing LR.
Esta técnica fue inventada por DeRemer en 1971.
Casi cualquier construcción sináctica de un LP puede
ser expresado de manera conveniente por una
gramática LALR.
Generadores de Parsers famosos como YACC (Yet
Another Compiler-Compiler-Johnson) producen un
parser LALR.
Para una gramática de un LP como Pascal una tabla de
parsing LALR ocuparía varios cientos de estados,
mientras que una tabla LR serían miles de estados.
47. 4.5 Administración de tablas de
símbolos.
Como se estudió en la unidad anterior,
durante el análisis de léxico se inicia la
construcción de la tabla de símbolos.
Esto ocurre al detectarse las declaraciones
en el programa fuente.
Sin embargo también el parser ayuda a
realizar esta tarea pues el es quien llama a
las respectivas rutinas semánticas para que
realicen funciones relacionada con la tabla
de símbolos (ver figura).
48. 4.6 Manejo de errores sintácticos y su
recuperación.
Dentro del código del parser predictivo estudiado en
clase se puede observar que el parser llama un
metodo “error” para el manejo de errores
sintácticos, que es cuando el parser obtiene un
token no esperado.
¿Cómo se maneja el error para esta clase de
parsers? Una forma simple es ejecutar una
excepción y parar la compilación. Esto como que no
es muy amigable par el usuario.
La mejor técnica es desplegar un mensaje de error
y recuperarse de este, para que otros posibles
errores sintácticos puedan ser encontrados en la
misma compilación.
49. (cont.)
Un error sintáctico ocurre cuando la cadena de
tokens de entrada no es una oración en el lenguaje.
La recuperación del error es una forma de encontrar
alguna oración correcta, similar a la cadena de
tokens.
Esto se puede realizar por medio de borrar,
reemplazar o insertar tokens.
Por ejemplo la recuperación de un error en S,
podría ser al insertar un token if,begin o print (o
pretender que existe en la cadena), desplegar el
mensaje del error y continuar la compilación.
50. Ejemplo:
void S ( ) { switch ( tok ) {
case If: eat ( if ); E ( ); eat ( then ); S ( );
eat ( else ); S ( ); break;
case begin: eat ( begin ); S ( ); L ( ); break;
case print: eat ( print ); E ( ); break;
default: print(“se esperaba if, begin o print”);
}}
Un problema que puede ocurrir al insertar un token faltante es que el programa
caiga en un ciclo infinito, por eso a veces es preferible y mas seguro borrar el
token, ya que el ciclo terminará cuando el EOF sea encontrado. Esta técnica
trabaja muy cercanamente con la tabla de parsing (cuando el parser se
implementa con tablas).
En un parser del tipo LR o LALR, la tabla de parsing tiene 4 acciones: shift,
reduce, accept y error (entrada nula). Cuando el parser encuentra una acción
error, se para el proceso de análisis y se reporta la falla.
51. 4.7 Generadores de código para
analizadores sintácticos
Como se vio al final del capítulo 3, Javacc es un generador
de scanners y de parsers (https://javacc.dev.java.net/).
Javacc produce un parser del tipo descendente (recursivo) o
LL(k) donde k (lookahead) puede ser cualquier número entero
positivo.
El parser producido puede correr en cualquier plataforma que
cuente con el compilador de Java.
En el ejemplo siguiente del uso de Javacc, utilizamos de
nuevo la gramática presentada anteriormente (sección 4.4.1)
para un parser predictivo. Ejecutamos Javacc con la anterior
gramática y obtendremos un parser recursivo descendente
para dicha gramática.
52. Ejemplo: gramática para estatutos
“begin”, “if” y “print”
PARSER_BEGIN(MiniParser)
public class MiniParser {
public static void main(String[] args) {
MiniParser parser;
try {
// RGC: added line
if( args.length == 0 )
parser = new MiniParser(System.in);
// RGC: added lines
else
parser= new MiniParser
( new java.io.FileInputStream( args[0] ) );
}
// RGC: End
parser.Program();
} catch (ParseException e) {
System.out.println(e.getMessage());
}
//RGC: added lines
catch( Exception e ) {
System.out.println(e.getMessage());
} //RGC :End
}
}
PARSER_END(MiniParser)
SKIP : {
""
| "t"
| "n"
| "r"
}
55. 5.1 Analizador semántico
Un compilador no solo tiene que revisar la sintaxis de
código fuente, si no también la semántica de este.
Al igual que en los lenguajes naturales (español, ingles,
etc.) en los lenguajes de programación existen reglas
semánticas para definir el significado de los programas,
estatutos, expresiones, etc.
Por ejemplo un error semántico es usar (en pascal ó
java) un identificador que no fue anteriormente
declarado.
Otro ejemplo de error semántico en un programa es
cuando este es compilado y y no se detectan errores
pero el momento de ser ejecutado este programa no
funciona correctamente.
56. 5.2 Verificación de tipos en
expresiones.
Cuando mezclamos diferentes tipos en una
misma expresión o que llamamos una rutina
que no existe existe un error semántico.
Una de las funciones del analizador smántico
es verificar que los tipos de una expresión
sean compatibles entre si.
Para hacer lo anterior el compilador cuenta
con información de los atributos (tipos,
tamaño, número de argumento, etc.) de los
identificadores en una tabla de símbolos.
57. 5.3 Conversión de tipos.
Algunas veces los tipos de una expresión o estatuto son
diferente.
Por ejemplo en la asignación,
a = b * c;
el tipo del resultado de evaluar b*c es diferente al de el
identificador a.
El compilador algunas veces con ciertos diferentes tipos puede
hacer una conversión interna en forma implícita para solucionar
el problema. Otras veces el programador explícitamente es el
que hace la conversión (casting).
Ejemplo:
float dinero;
int cambio;
dinero = (float) cambio;
58. 5.4 Acciones agregadas en un Analizador
sintáctico descendente (top-down).
En un parser recursivo-descendente, el código de las acciones semánticas
es mezclado dentro del flujo de control de las acciones del parser. En un
parser especificado en javaCC, las acciones semánticas son fragmentos de
código de programa en java unido a las producciones gramáticales.
Cada símbolo terminal y noterminal puede asociarse con su propio tipo de
valor semántico.
Por ejemplo en la siguiente gramática para YACC de una calculadora
simple, el tipo asociado con exp e INT podría ser int:
%token INT PLUS MINUS TIMES UMINUS
%start exp
%left PLUS MINUS
%left TIMES
%left UMINIS
exp: INT | exp PLUS exp | exp MINUS exp | exp TIMES exp 1
MINUS exp %prec UMINUS
59. (cont.)
Los otros tokens no necesitarían tener un
valor.
Por otra parte el tipo asociado a un token
debe por supuesto coincidir con el tipo de
token que el scanner retorne.
Para una regla ABCD, la acción semántica
debe retornar un valor cuyo tipo es el
asociado al noterminal A. Pero puede
construír este valor de los valores asociados
a los terminales y noterminales B, C, D.
60. Recursivo-descendente
En un parser recursivo-descendente, las acciones
semánticas son los valores retornados por las
funciones de parsing, o los efectos laterales de
esas funciones o ambos.
Por cada símbolo terminal y noterminal, asociamos
un tipo (desde el lenguaje de implementación del LP
del compilador) de valor semántico representando
frases derivadas desde ese símbolo.
El siguiente programa es un intérprete recursivo
descendente para una parte de la gramática en la
cual eliminamos la recursión por la izquierda (por
conveniencia la volvemos a mostrar):
61. (cont.)
S E$ E T E’ E’ + T E’ E’ - T E’ E’ λ
T F T’ T’ * F T’ T’ / F T’ T’ λ
F id F num F (E)
Los tokens ID y NUM deben ahora acarrear valores de tipo string e int,
respectivamente. Asumiremos que existe una tabla “lookup” que mapea
identificadores a enteros. El tipo asociado con E, T, F, etc., es int, y la acción
semántica es fácil de implementar.
62. Interprete Acciones semánticas
class Token2 {
int kind; Object val;
Token2(int k, Object v) int T() {switch(tok.kind) {
{ case ID:
kind=k; case NUM:
val=v; case LPAREN: return Tprime(F());
} default: print("2 esperaba ID, NUM o parent izq");
} //skipto(T_follow);
final int EOF=0, ID=1, NUM=2, PLUS=3, return 0;
MINUS=4,LPAREN=5, RPAREN=6, TIMES=7; }}
int lookup(String id) { …. }
int F_follow[] = {PLUS,TIMES,RPAREN,EOF}; int Tprime(int a) {switch (tok.kind) {
case TIMES: eat(TIMES); return Tprime(a*F());
int F() {switch(tok.kind) { case PLUS:
case ID: int i=lookup((String)(tok.val));advance()return i; case RPAREN:
case NUM: int i=((integer)(tok.val)).intVal(); case EOF: return a;
advance();return i; default: print("3 esperaba ID, NUM o parent izq");
case LPAREN: eat(LPAREN); //skipto(T_follow);
int i=E(); return 0;
eatOrSkipTo(RPAREN,F_follow); }}
return i;
case EOF: void eatOrSkipTo(int expected, int[] stop) {
default: print("1 esperaba ID,NUM, o parent izq"); if (tok.kind==expected)
//skipto(F_follow); return 0; eat(expected);
}} else {print("4 esperaba ID, NUM o parent izq");
//skipto(stop);}
int T_follow[]= {PLUS,RPAREN,EOF}; }
63. Parser Automáticamente generado
Una especificación del parser para javaCC consistiría de un conjunto de reglas
gramaticales, cada una anotada o agregada con una acción semántica el cual sería
un estatuto java.
Ejemplo:
void Start(): Int Term():
{ int i; } { int a,i; }
{ i=Exp() <EOF> {System.Out.println(i);} { a=factor()
} ( “*” i=Factor() { a=a*i;}
Int Exp(): | “/” i=Factor() {a=a/i;}
{ int a,i; } )*
{ a=Term() { return a; }
( “+” i=Term() {a=a+i;} }
| “-” i=Term() {a=a-i;} Int Factor():
)* { Token t; int i; }
{ return a; } { t=<IDENTIFIER> { return lookup(t.image); }
} | t=<INTEGER_LITERAL>
{return Integer.parseInt(t.image); }
| “(“ i=Exp() “)” {return i; }
}
64. Árboles de Parsing Abstractos
Para mejorar la modularidad del compilador, es recomendable separar detalles de la
sintaxis con detalles de la semántica (chequeo de tipos y traducción a código
máquina).
Una forma de hacerlo es producir un árbol de sintaxis abstracta (una forma
condensada de árbol de parsing útil para representar construcciones del LP).
Por ejemplo la producción S if B then S1 else S2 pudiera aparecer en un arbol
sintáctico como:
Árbol
Est-if
Árbol If-then-else De
sintáctico parsing
If Exp ( Exp ) Est
B S1 S2
En un árbol sintáctico, los operadores y las palabras claves (reservadas)
no aparecen como hojas, sino que están asociadas con el nodo interior
que sería el padre de esas hojas en el arbol de parsing.
65. (cont.)
Otro ejemplo es en el árbol de parsing:
L
E + T
T * F F
F 4 8
2 +
Cuyo árbol sintáctico abstracto sería: * 8
2 4
66. Ejemplo:
La gramática siguiente nos muestra una sintaxis
abstracta de un lenguaje para expresiones:
EE+E EE-E EE*E
EE/E Eid Enum
Esta gramática es impráctica para un parser ya que
es ambigua pues no tiene precedencia de
operadores.
Sin embargo, esta gramática no es para el parser.
El analizador semántico podría usarla el cual no se
molesta por la ambiguedad puesto que ya tiene su
arbol.
67. Árboles de Sintaxis en Java
EnJava las estructuras de datos para el
árbol de sintaxis contienen una clase
abstracta para cada noterminal y una
subclase para cada producción. Así, las
clases de el programa siguiente son las
clases de la sintaxis abstracta para la
gramática de la diapositiva anterior.
68. Programa de clases para Exp
public abstract class ExpCh4 {
public abstract int eval();
}
class PlusExp extends ExpCh4 {
private ExpCh4 e1,e2; class DivideExp extends ExpCh4 {
public PlusExp(ExpCh4 a1, ExpCh4 a2) private ExpCh4 e1,e2;
{e1=a1; e2=a2;} public DivideExp(ExpCh4 a1, ExpCh4 a2)
public int eval() { {e1=a1; e2=a2;}
return e1.eval()+e2.eval(); public int eval() {
} return e1.eval()/e2.eval();
} }
class MinusExp extends ExpCh4 { }
private ExpCh4 e1,e2; class Identifier extends ExpCh4 {
public MinusExp(ExpCh4 a1, ExpCh4 a2) private String f0;
{e1=a1; e2=a2;} public Identifier(String n0) {f0=n0;}
public int eval() { public int eval() {
return e1.eval()-e2.eval(); return (7); //return lookup(f0);
} }
} }
class TimesExp extends ExpCh4 { class IntegerLiteral extends ExpCh4 {
private ExpCh4 e1,e2; private String f0;
public TimesExp(ExpCh4 a1, ExpCh4 a2) public IntegerLiteral(String n0) {f0=n0;}
{e1=a1; e2=a2;} public int eval() {
public int eval() { return (4);
return e1.eval()*e2.eval(); //return Integer.parseInt(f0);
} }
} }
69. (cont.)
Ahora veamos un intérprete para el lenguaje de
expresiones de la gramática de sección 4.1.1. Por
conveniencia la mostramos de nuevo.
S E$
ETE’ E’ + T E’ E’ - T E’ E’ λ
T F T’ T’ * F T’ T’ / F T’ T’ λ
F id F num F (E)
Nuestro intérprete primero construye árboles
sintácticos y después los interpreta. El siguiente
código es el de la gramática JavaCC con acciones
semánticas para interpretar (evaluar) y producir
(construir) árboles sintácticos. Cada clase de nodos
de árbol sintáctico contiene una función eval que
cuando es llamada retorna el valor de la expresión
representada.
71. VISITADORES
Es una técnica de patrones (opuesta a la orientada
a objetos) que se puede usar para implementar el
árbol sintáctico del compilador o intérprete. Un
visitador es un objeto que contiene un método visit
por cada clase de árbol sintáctico. Cada clase de
árbol sintáctico debe contener un método accept.
Cada método accept sirve como enganche para
cada diferente tipo de operación sobre el árbol y es
llamado por un visitador donde tiene una tarea:
pasar el control de ida y venida (back and forth)
entre el visitador y las clases del árbol sintáctico.
72. (cont.)
A continuación veremos un ejemplo del
intérprete de expresiones anterior pero ahora
implementado con visitadores. Cada visitador
implementa la interfase Visitor. Cada
método accept toma un visitador como
argumento y cada método visit toma un
objeto de un nodo del árbol sintáctico como
argumento.
73. Sintáxis Abstracta para MiniJava
En la siguiente figura (siguiente diapositiva) mostramos las clases
de la sintaxis abstracta para minijava. Solo los constructores son
mostrados en la figura. Cada una de las clases de listas se
implementa en la misma forma. Por ejemplo:
public class ExpList {
private Vector list;
public ExpList() {
list=new vector();
}
Public void addElement (Exp n) {
list.addElement(n);
}
public Exp elementAt(int i) {
return (exp)list.elementAt(i);
}
public int size() {
return list.size();
}
}
74. Package syntaxtree;
Program(MainClass m, ClassDeclList cl)
MainClass(Identifier i1, Identifier i2, Statement s)
Abstract class ClassDecl
ClassDeclSimple(Identifier i, VarDeclList vl, MethodDeclList ml)
ClassDeclExtends(Identifier i, identifier j, VarDeclList vl, MethodDeclList ml)
VarDecl(Type t, Identifier i)
MethodDecl(Type t, Identifier i, FormalList fl, VarDeclList vl, StatementList,
Exp e)
Formal(Type t, Identifier i)
Abstract class Type
IntArrayType() BooleanType() IntegerType() IdentifierType(String s)
Abstract class Statement
Block(StatementList sl)
If(Exp e, Statement s1, Statement s2)
While(Exp e, Statement s)
Print(Exp e)
Assign(Identifier i, Exp e)
ArrayAssign(Identifier i, Exp e1, Exp e2)
76. Arbol Sintáctico
Cada una de las clases que no son listas
tiene un método accept para usarse con el
patrón visitador. La interface Visitador se
muestra en la siguiente diapositiva.
77. public interface Visitor {
public void visit(Program n);
public void visit(MainClass n);
public void visit(ClassDeclSimple n);
public void visit(ClassDeclextends n);
public void visit(VarDecl n);
public void visit(MethodDecl n);
public void visit(Formal n);
public void visit(IntArrayType n);
public void visit(BooleanType n);
public void visit(IntegerType n);
public void visit(IdentifierType n);
public void visit(Block n);
Visitador public void visit(If n);
public void visit(While n);
MiniJava public void visit(Print n);
public void visit(Assign n);
public void visit(ArrayAssign n);
public void visit(And n);
public void visit(LessThan n);
public void visit(Pluss n);
public void visit(Minus n);
public void visit(Times n);
public void visit(ArrayLoockup n);
public void visit(ArrayLength n);
public void visit(Call n);
public void visit(IntegerLiteral n);
public void visit(True n);
public void visit(False n);
public void visit(IdentifierExp n);
public void visit(This n);
public void visit(NewArray n);
public void visit(NewObject n);
public void visit(Not n);
public void visit(Identifier n);
}
78. (cont. Arbol Sintáctico)
Podemos construir un árbol sintáctico usando expresiones new
anidadas. Por ejemplo el árbol sintáctico para el estatuto MiniJava:
x = y.m(1,4+5);
usaría el siguiente código:
ExpList el= new ExpList();
el.addElement(new IntegerLiteral(1));
el.addelement(new Plus(new IntegerLiteral(4),
new IntegerLiteral(5)));
Statement s = new Assign(new Identifier “x”),
new Call(new identifierExp(“y”),
new Identifier(“m”),
el));
79. 5.5 Pila semántica en un analizador
sintáctico ascendente (bottom-up).
Como fue visto en el capitulo anterior (4), un parser ascendente
utiliza durante el análisis una pila. En esta va guardando datos
que le permiten ir haciendo las operaciones de reducción que
necesita.
Para incorporar acciones semánticas como lo es construir el
árbol sintáctico, es necesario incorporar a la pila del parser otra
columna que guarde los atributos de los símbolos que se van
analizando.
Estos atributos estarían ligados a la correspondiente producción
en la tabla de parsing
(consultar sección 5.3 del libro de “Aho, Ullman, Sethi” para ver
mas detalles de la implementación).
80. 5.6 Administración de la tabla de
símbolos
El análisis semántico conecta las definiciones de las
variables con sus usos, checa que cada expresión
tenga un tipo correcto y traduce la sintaxis abstracta
a una representación mas simple para generar
código máquina.
Esta fase es caracterizada por el mantener la tabla
de símbolos (también llamada “environment”) la
cual mapea identificadores con sus tipos y
localidades.
Cada variable local en un programa tiene un ámbito
(scope) dentro del cual es visible. Por ejemplo, en
un método MiniJava m, todos los parámetros
formales y variables locales declarados en m son
visibles solo hasta que finalice m.
81. (cont.)
Un ambiente es un conjunto de atados
(bindings) denotados por . Por ejemplo,
podemos decir que el ambiente z0 contiene
los atados {gstring,aint}, que significa
que el identificador a es una variable entero y
g es una variable string.
82. Ejemplo:
1 Class C {
2 int a, int b; int c;
3 public void m() {
4 System.out.println(a+c);
5 int j=a+b;
6 String a=“hello”;
7 System.out.println(a);
8 System.out.println(j);
9 System.out.println(b);
10 }
11 }
Suponer que compilamos esta clase en el ambiente z0. Las declaraciones de
campo en línea 2 nos da la tabla z1 igual a z0 + {aint,bint,cint}. Los
identificadores en línea 4 pueden encontrarse (look up) en ambiente z1. En
línea 5, la tabla o ambiente z2=z1+{jint} es creada; y en línea 6, z3=z2+
{astring} es creada.
83. Implementación de la Tabla
Existendos opciones: El estilo funcional
donde cuando z1 existe y z2 es creado, z1
sigue existiendo. Y el imperativo en donde z1
es destruido al crearse z2. Mientras z2 existe
no podemos mirar z1. Pero al morir z2, z1 de
nuevo existe.
84. Múltiple Tablas de Símbolos
Enalgunos LP pueden existir varios
ambientes a la vez: Cada módulo, o clase o
registro en el programa tiene una tabla de
símbolos z propia. Ejemplos (ML y Java).
Structure M = struct Package M;
structure E = struct class E {
val a= 5; static int a=5;
end }
structure N = struct Class N {
val b=10 static int b=10;
val a=E.a+b static int a=E.a+b;
end }
structure D = struct Class D {
val d=E.a+N.a static int d=E.a+N.a;
end }
end
85. (cont.)
Al anlizar los 2 programas anteriores, sea z0 el
ambiente base conteniendo funciones predefinidas,
y sea
z1={aint}
z2={Ez1}
z3={bint,aint}
z4={Nz3}
z5={dint}
z6={Dz5}
z7=z2+z4+z6
86. (cont.)
En ML, N es compilado usando el ambiente
z0+z2 para buscar los identificadores en la
tabla;D es compilado usando z0+z2+z4 y el
resultado del análisis es {Mz7}.
En Java, referencias adelantads son
permitidas (dentro de N la expresión D.d
sería legal), asi E,N y D son compilados en el
ambiente z7.
87. TABLAS DE SIMBOLOS EN
LENGUAJES IMPERATIVOS
Un programa grande puede contener miles de
distintos identificadores. Esto hace que la búsqueda
en la tabla (loock up) tenga que ser eficiente.
En ambientes imperativos usualmente se usan
tablas de dispersión. La operación z’=z+{at} es
implementada insertando t en la tabla de dispersión
usando la llave a. Una tabla de dispersión con
encadenamiento externo funciona bien y soporta
eliminación fácil de at para recuperar z al final del
ambito de a. El siguiente programa implementa una
tabla de dispersión. El “bucket i” es una lista ligada
de todos los elementos cuya llave genere “i mod
SIZE”.
88. (cont.)
Considere z+{at2} cuando z ya contiene
at1. La función insert deja at1 en el
“bucket” y pone at2 antes en la lista.
Entonces, cuando pop se realiza después del
ambito de a, z es restaurado.
89. SIMBOLOS
Para evitar comparaciones innecesarias de
cadenas podemos convertir cada cadena a
un símbolo, y así todas las diferentes
ocurrencias de cualquier cadena se
conviertan a un mismo objeto símbolo.
El módulo símbolo implementa los símbolos
y tiene estas propiedades:
Comparar símbolos por igualdad o por mayor es
rápido (comparación por apuntador o por entero).
Extraer una llave “hash” de tipo entero es rápido
90. (cont.)
Los ambientes son implementados en la clase Symbol.Table
como Tables mapeando Symbols a ligados (bindings).
Para eso se manejan ligaduras para diferentes propósitos en el
compilador – ligadura para tipos, para variables, para funciones,
etc.
Entonces, una ligadura es un Objeto.
Para implementar la clase Symbol, usaremos el método intern()
(java.lang.String), para darnos un objeto único a partir de una
cadena de caracteres.
Para el uso de la tabla de símbolos usaremos
java.util.Hashtable. La función beginScope “recuerda” el estado
actual de la tabla y endScope restaura la tabla a donde estaba
en el mas reciente beginScope que no ha terminado.
91. (cont.)
Cuando la atadura xb es metido a la tabla (table.put(x,b)), x es
dispersado a un índice i, y un objeto “binder” xb es puesto en
la cabeza de la lista ligada para el bucket i.
Si la tabla ya tiene una ligadura xb’, esto permanecería en el
bucket, pero escondido por xb. Esto es importante ya que
soportaría la implementación de undo (beginScope y endScope).
También deberá existir una pila auxiliar, que muestre en que
orden los símbolos son metidos (pushed) a la tabla de símbolos.
Cuando xb es encontrado, entonces x es metido a la pila
(beginScope). Entonces, para implementar endScope, los
símbolos deben sacarse de la pila.
92. Chequeo de Tipos en MiniJava
¿Con que se llena una tabla de símbolos? Esto es,
¿Qué es la ligadura o “binding”?
Para realizar el chequeo de tipos de programas
MiniJava, la tabla de símbolos debe contener toda
la información declarada:
Cada nombre de variable y nombre de parámetro formal
debe ser ligado a su tipo.
Cada nombre de método debe ser ligado a sus
parámetros, tipo de resultado y variables locales.
Cada nombre de clase debe ser ligado a su variable y
declaraciones de métodos.
Liga a página con mas información.
93. (cont.)
Por ejemplo, considere la siguiente figura,
que muestra un programa y su tabla de
símbolos. PARAMS
p int
Class B {
q int
C f; int [ ] j; int q;
public int start(int p, int q) { FIELDS
int ret; int a; f C LOCALS
/* …… */ j int [ ] ret int
return ret; q int a int
B
} C METHODS
public boolean stop(int p) { start int
/* ….. */ stop bool PARAMS
return false;
p int
}
} …… LOCALS
Class C {
/* ….*/
}
94. (cont.)
Los tipos primitivos en MiniJava son int y boolean;
todos los otros tipos son arreglo de enteros o
nombres de clases.
Por simplicidad todos los tipos son “string”, en lugar
de símbolos; esto nos permite checar igualdad de
tipos por medio de comparación de “strings”.
El chequeo de tipos de un programa MiniJava
ocurre en dos fases. Primero, construimos la tabla
de símbolos y después checamos los tipos de los
estatutos y las expresiones. Lo hacemos en dos
fases porque en MiniJava (igual que en Java) las
clases son mutuamente recursivas.
95. (cont.)
La primera fase de el checador de tipos se puede
implementarse por medio de un visitador que visite
los nodos del árbol sintáctico de MiniJava y
construya la tabla de símbolos.
Por ejemplo el método visitador en el siguiente
programa maneja declaraciones de variables. Este
agrega el nombre de la variable y el tipo a la
estructura de datos para la clase actual que mas
tarde será agregada a la tabla de símbolos.
El método visitador checa si la variable ya fue
declarada anteriormente.
96. Método Visitador
Class ErrorMsg {
boolean anyErrors;
void complain (String msg) {
anyErrors = true;
System.out.println(msg);
}
}
// Type t;
// Identifier i;
Public void visit(VarDecl n) {
Type t = n.t.accept(this);
String id= n.i.toString();
if (currMethod ==null) {
if (!currClass.addVar(id,t))
error.complain(id + “is already defined in “ + currClass.getId());
} else if (!currentMethod.addVar(id,t))
error.Complain(id + “is already defined in “
+ currClass.getId( ) + “.” + currMethod.getId( ));
97. (cont.)
La segunda fase del checador de tipos puede ser implementada
por un visitador que cheque tipos de todas los estatutos y
expresiones.
El tipo del resultado de cada método visitador es String, que
representa los tipos de MiniJava.
La idea es que cuando el visitador visita una expresión, entonces
retorna el tipo de esa expresión.
El siguiente método (siguiente diapositiva) es el visitador que
implementa la adición (plus) e1 + e2. En MiniJava ambos
operandos deben ser de tipo entero (el checador revisa esto) y el
resultado debe ser entero (el checador retorna este tipo).
98. Método Visitador para
expresiones Plus
// Exp e1, e2;
Public Type visit(Plus n) {
if (! (n.e1.accept(this) instanceOf IntegerType) )
error.complain(“Left side of LessThan must be of type integer”);
if (! (n.e2.accept(this) instanceOf IntegerType) )
error.complain(“Right side of LessThan must be of type integer”);
return new IntegerType( );
}
99. 5.7 Manejo de errores semánticos.
Cuando el checador de tipos detecta un error de
tipos o un identificador no declarado, debe imprimir
el mensaje de error y continuar.
Esto debido a que normalmente el programador
prefiere que le describan todos los errores posibles
del programa fuente.
Esto quiere decir, que si un error de tipos es
encontrado, no debe producirse un programa objeto
por parte del compilador.
Así, las siguientes fases no deben ejecutarse.
Hasta esta etapa (chequeo de tipos), la parte del
compilador se conoce con el nombre de “front End”.
100. REGISTROS DE ACTIVACION
En casi cualquier LP, una función (método)
puede tener variables locales que son
creadas cuando se llama la función (al entrar
a esta).
Diferentes invocaciones a la función pueden
existir a la vez, y cada invocación tiene su
propia “instanciación” de variables.
101. (cont.)
En el siguiente método de Java
Int f(int x) {
int y= x+x;
if (y<10)
return f(y);
else
return y-1;
Una nueva instancia de x es creada (e inicializada por el
llamador de “f”) cada vez que “f” es llamada. Debido a
que existen llamadas recursivas, muchas de esas x
existen simultáneamente. Similarmente, una nueva
instancia de y es creada cada vez que el cuerpo f es
iniciado.
102. (cont.)
En muchos LP (incluyendo Pascal, C y java),
las variables locales son destruidas cuando
una función retorna. Ya que las variables
locales son creadas y destruidas en una
forma LIFO, podemos usar una pila para
manejarlas.
103. MARCOS DE PILA
Debido a que se trabaja con bloques de datos por
función un “push” y “pop” no funciona.
Entonces la pila es tratada como si fuera un gran
arreglo, con un registro especial- el “stack pointer”
que apunta a una localidad.
Todas las localidades después del apuntador son
basura y todas las que están antes están
asignadas.
El área en la pila dedicada a las variables locales,
parámetros, dirección de retorno y otras variables
temporales para una función es llamada el registro
de activación o marco de pila de la función.
104. (cont.)
El diseño de la estructura de los marcos es
de acuerdo con la arquitectura y el LP que se
compila.
Aunque normalmente el constructor de la
arquitectura define un diseño de marco
standard para todos los compiladores para
esa arquitectura.
105. Ejemplo: Un marco de pila
Direcciones de Memoria
mas altas
Argumentos Argumento n Marco anterior
de entrada …
…
Argumento 1
Apuntador Liga estática
Del marco
Variables
locales
Marco
Dirección retorno
actual
Temporales
Registros salvados
Argumento m
Argumentos …
De salida …
Argumento 1
Apuntador Liga estática
De pila
Marco siguiente
106. Marco de Pila
Los argumentos de entrada son los pasados por el
llamador (técnicamente son parte del marco anterior
pero pueden accesarse usando un desplazamiento
del apuntador de marco).
Cuando la función actual llama otras funciones,
puede usar el espacio de los argumentos de salida
para pasar parámetros.
La dirección de retorno es creada por la instrucción
CALL.
Las variables locales también tienen su espacio.
Las variables mantenidas en registros algunas
veces son salvadas a memoria.
107. El Apuntador de Marco (FP)
Suponer que una función g(…) llama la función f(a1,…an).
Diremos que g es el llamador (caller) y f el llamado (callee). Al
entrar a f, el apuntador de la pila (SP) apunta al primer
argumento que g pasa a f. Al entrar, f coloca un marco solo con
restar el tamaño del marco de el SP.
El viejo SP se convierte en el actual FP y el viejo FP es salvado
en el marco.
Cuando FP termina, solo copia FP de regreso a SP y regresa el
valor viejo salvado de FP.
Si los marcos son siempre del mismo tamaño entonces no es
necesario contar con FP y todo se simplifica sumando o
restando la constante framesize a SP.
108. Registros
Por eficiencia, es importante mantener las variables
locales, resultados intermedios y otros valores en
registros en lugar de la pila de marcos.
Si función f llama a g y ambas hacen uso de registro
r, entonces r debe ser salvado (dentro de la pila de
marcos) antes de que lo use g y restaurado (desde
la pila) después de que termine g.
¿de quien es responsabilidad de salvar r? ¿de f o
g? si lo salva f se dice que r es un registro caller-
save; si lo salva g se llama callee-save.
109. Pase de Parámetros
Estudios actuales han mostrado que
raramente una función pasa mas de 4
parámetros.
Debido a esto, la mayoría de las máquinas
definen que los primeros k argumentos (con
k=4) se pasan en registros y el resto en
memoria.
110. Direcciones de Retorno
Si g llama a f, entonces si la instrucción call
dentro de g está en dirección a, el lugar de
retorno en g es a+1, la siguiente instrucción
del call.
En máquinas modernas la dirección de
retorno es pasada a un registro en lugar de la
memoria.
En funciones “hoja” la dirección no necesita
ponerse en la pila.
111. Registros vs. Memoria
Registros siempre deben usarse en asignación a
menos que:
La variable sea pasada por referencia
La variable es accesada por una función anidada dentro de
la función actual.
El valor es demasiado grande para un registro.
La variable es un arreglo, donde es necesario realizar
aritmética de direcciones.
El registro que contiene la variable es necesitado para
otros propósitos.
Existen demasiadas variables locales y valores temporales
112. Ligas Estáticas (Static Links)
En LP que admiten funciones anidadas (Pascal,ML
y Java) las funciones de mas adentro pueden usar
variables declaradas en funciones de mas afuera
(Estructuras de Bloque).
En el siguiente programa (sig. Diapositiva) la
función write hace referencia a la variable de afuera
output e indent hace referencia a n y output. Para
cumplir con esto, indent debe tener acceso no solo
a su propio marco (para i y s) sino también a los
marcos de show (por n) y prettyprint (por output).
113. Programa de funciones Anidadas
Type tree= {key: string, left: tree, right: tree}
Function prettyprint(tree:tree): string=
let
var output := “ “
function write(s:string) =
output :=concat(output,s)
function show(n:int, t:tree) =
let function indent(s:string)=
(for i:= 1 to n
do write(“ “);
output:=concat(output,s);write(“ “);
in if t=nil then indent(“.”)
else (indent(t.key);
show(n+1,t.left);
show(n+1,t.right))
end
in show(0,tree); output
end
114. Ligas Estáticas (cont.)
Existen varios métodos para solucionar lo anterior:
Siempre que una función f sea llamada, puede pasarse un
apuntador a el marco de la función que estáticamente
encierra a f; este apuntador es la liga estática.
Un arreglo global puede mantenerse, conteniendo -en
posición i - un apuntador a el marco del procedimiento mas
recientemente activado cuyo profundidad de anidamiento
estático es i. Este arreglo es llamado un “display”.
A continuación describimos el método de liga
estática para el ejemplo de la diapositiva anterior.
115. (cont.)
Línea 21: prettyprint llama show, pasando el apuntador del marco del propio
prettyprint como una liga estática de show.
Línea 10: show guarda su liga estática (la dirección del marco de prettyprint)
dentro de su propio marco.
Línea 15: show llama indent, pasando su propio apuntador de marco como liga
estática de indent.
Línea 17: show llama a show,pasando su propia liga estática (no su propio
apuntador de marco) como la liga estática.
Línea 12: indent usa el valor n del marco de show. Para hacer esto, trae un
desplazamiento apropiado de la liga estática de indent (que apunta al marco de
show).
Línea 13: indent llama a write. Debe pasar el apuntador de marco de
prettyprinter como la liga estática. Para obtener esto, primero trae un
desplazamiento de su propia liga estática (desde el marco de show), la liga
estática que había sido pasada a show.
Líea 14: indent usa la variable output del marco de prettyprint. Para hacer esto,
comienza con su propia liga estática, entonces trae a show, y luego trae a
output.
117. 6.1 Lenguajes intermedios.
El código intermedio en una estructura de código
cuya complejidad está entre un código fuente en un
lenguaje de alto nivel y el código máquina.
Código fuente Código intermedio Código Objeto
Un compilador produce primero un código
intermedio, pues producir directamente el código
objeto resulta sumamente complicado e ineficiente.
118. (cont.)
Ventajas de producir código intermedio:
Más fácil de producir código objeto después,
si se parte de un código intermedio.
Facilita y hace eficiente la optimización de
código (algunos tipos de optimización).
El código intermedio es transportable (puede
usarse en diferentes arquitecturas) ya que no
hace referencia a componentes de la
máquina.
119. 6.2 Notaciones.
Infija. Es la notación habitual. El orden es primer
operando, operador, segundo operando.
Ejemplo: a/b/c
La notación infija tiene el problema de que en
expresiones con más de un operador existe
ambiguedad sobre cual es el orden de evaluación.
Por ejemplo, la expresión 8/4/2 se puede interpretar
como (8/4)/2 o bien como 8/(4/2). Las otras
notaciones (prefija y postfija) no sufren este
problema.
120. (cont.)
Postfija. El orden es primer operando, segundo operando, operador.
Ejemplo: La expresión X+Y-X*Y
en notación Postfija es XY+XY*-
Por lo general la notación postfija se emplea en máquinas de pilas ya que la pila facilita la
ejecución. Al analizar una notación postfija de izquierda a derecha, cada vez que se detecta un
operando se mete a la pila. La ocurrencia de un operador con ‘m' operandos significa que el
enésimo operando estará m-n posiciones por debajo del tope de la pila. Después se sacan los
operandos de la pila y se mete el resultado de la operación.
Por ejemplo, suponer que X=1 y Y=2.
Las operaciones serían:
push 1 (meter 1)
push 2
' + ' requiere de 2 operandos, se sacan, se suman y se mete el resultado (3)
push 1
push 2
' * ' se mete el resultado(2)
' - ' se mete el resultado (1)
Notación prefija: El orden es operador, primer operando, segundo operando.
121. 6.3 Representación de código
intermedio.
Existen
muchas clases de representaciones
intermedias. Algunas de las mas comunes
son:
Notación polaca.
Código P (Pascal).
Triples y Cuadruples.
Bytecodes (Java)
MSIL (C#)
122. Notación Polaca
También conocida como notación postfija. Se
utiliza como se dijo anteriormente en
máquinas de Pila.
Ejemplos:
Pascal Notación Polaca
a+b-c ab+c-
a+b*c abc*+
a+b*c+d abc*+d+
123. Código P
Se usó como código intermedio y objeto en las
primeras implementaciones de Pascal.
El código P era interpretado en una máquina
abstracta.
Algunas implementaciones de Basic y Pascal usan
código P el cual después es traducido a código
nativo por un compilador “Just-in-Time”.
La máquina P está orientada a usarse en una pila
(stack-oriented).
124. Ejemplo:
Insn. Stack Stack Description
before after
adi i1 i2 i1+i2 add two integers
Adr r1 r2 r1+r2 add two reals
dvi i1 i2 i1/i2 integer division
ldci i1 i1 load integer constant
mov a1 a2 move
not b1 ~b1 boolean negation
125. Triples y Cuadruplos
(Código de 3 Direcciones)
Ha sido uno de los mas populares. Sus
instrucciones constan de 3 direcciones o registros
para 2 argumentos u operandos y el resultado.
Su formato es:
resultado:= argumento1 operador argumento2
Donde resultado, argumento1 y argumento2 pueden
ser constantes, identificadores y variables
temporales definidos por el compilador mientras que
operador representa una operación arbitraria. Esta
forma se denomina Cuádruplo.
126. (cont.)
EJEMPLO: Z:= X + Y – X * Y
ADD X Y VAR1
MUL X Y VAR2
SUB VAR1 VAR2 VAR3
STORE VAR3 Z
127. (cont.)
EJEMPLO: (Estructure de Control):
If (a==b)
a=0;
else
a=1;
En cuadruplos tendríamos:
1 - A B t1
2 JnZ t1 5
3 = 0 A
4 JP 6
5 = 1 A
6
128. (cont.)
En el código de 2 direcciones se evita el uso se
variables temporales. El formato es:
Operador argumento1 argumento2
EJEMPLO: Z = X + Y - X * Y
1. ADD X Y
2. MUL X Y
3. SUB (1) (2)
4. STORE (3) (Z)
Donde los números entre paréntesis representan
apuntadores a la secuencia de operaciones de 2 direcciones.
129. Java Bytecodes
El bytecode es un código intermedio más abstracto que el
código máquina. Habitualmente viene a ser un archivo binario
que contiene un programa ejecutable similar a un módulo objeto
o código máquina producido por el compilador.
El bytecode recibe su nombre porque generalmente cada código
de operación tiene una longitud de un byte, si bien la longitud del
código de las instrucciones varía.
Cada instrucción tiene un código de operación entre 0 y 255
seguido de parámetros tales como los registros o las direcciones
de memoria. Esta sería la descripción de un caso típico, si bien
la especificación del bytecode depende ampliamente del
lenguaje.
130. (cont.)
Como código intermedio, se trata de una forma de salida
utilizada por los implementadores de lenguajes para reducir la
dependencia respecto del hardware específico y facilitar la
interpretación.
Menos frecuentemente se utiliza el bytecode como código
intermedio en un compilador. Algunos sistemas, llamados
traductores dinámicos o compiladores just-in-time (JIT) traducen
el bytecode a código máquina inmediatamente antes de su
ejecución para mejorar la velocidad de ejecución.
Los programas en bytecode suelen ser interpretados por un
intérprete de bytecode (en general llamado máquina virtual, dado
que es análogo a un ordenador).
131. (cont.)
Su ventaja es su portabilidad: el mismo código binario puede ser
ejecutado en diferentes plataformas y arquitecturas. Es la misma
ventaja que presentan los lenguajes interpretados.
Sin embargo, como el bytecode es en general menos abstracto,
más compacto y más orientado a la máquina que un programa
pensado para su modificación por humanos, su rendimiento
suele ser mejor que el de los lenguajes interpretados.
A causa de esa mejora en el rendimiento, muchos lenguajes
interpretados, de hecho, se compilan para convertirlos en
bytecode y después son ejecutados por un intérprete de
bytecode.
Entre esos lenguajes se encuentran Perl, PHP y Python. El
código Java se suele trasmitir como bytecode a la máquina
receptora, que utiliza un compilador just-in-time para traducir el
bytecode en código máquina antes de su ejecución.
132. La Máquina Virtual de Java
(JVM)
La siguiente liga nos lleva a la información de
lo que es la JVM y mas sobre Java
bytecodes.
133. Árboles de Ensamblador
Un paso antes de generar Ensamblador Real
Permite mas fácil optimización de código
Primero vamos a estudiar la implementación
de asignación de memoria en el código
generado (stack y heap).
134. Implementación de variables
En Java las variables locales son
almacenadas en la pila y en registros.
Las variables de arreglos y de clases son
almacenadas en el heap.
Registros de Activación (RA).
Son los segmentos de la pila que contienen las
variables para una función. También se le llama
“stack frame”. También almacenan valores de
registros (saved registers), retornos, parámetros,
etc.
135. Por ejemplo, la función:
Int foo() {
int a;
int b;
/* body of foo */
}
Tendría el registro de activación
a Existen dos apuntadores. FP que
FP
apunta al inicio del RA actual y SP que
b apunta a la primera localidad vacía. El
Registros valor retornado de una función es
salvados puesto en un registro especial en lugar
de la pila. Cuando una función es
SP llamada los valores de los parámetros
de entrada son puestos en la pila, y
cuando la función retorna un valor, el
valor es almacenado en el registro de
resultado.
136. (cont.)
Como el FP siempre apunta al comienzo del
RA actual, podemos acceder a las variables
locales usando un offset desde el FP.
Los parámetros de funciones son
almacenados en RA de la función que llama,
no la que es llamada. Los parámetros de
entrada a una función pueden ser accesados
por medio del FP, usando un offset en la
dirección opuesta a las variables locales. El
formato completo de un RA es mostrado en
la siguiente diapositiva.
137. Formato completo de RA
Input Parameter n
Previous Activation Record …
Input Parameter 2
Input Parameter 1
FP
Local Variables
Current Activation Record
Saved Registers
SP
138. Considere el siguiente programa:
void foo(int a, int b);
void foo(int c, int d);
void main() {
int u;
int v;
/* Label A */
bar(1,2);
}
void bar(int a, int b) {
int w;
int x;
foo(3,4);
}
void foo(int c, int d) {
int y;
int z;
/* Label B */
}
139. En label A de el programa, la pila de RA se miraría de la
forma siguiente
u FP
v Activation
Record for
Saved Registers main
SP
La variable local u puede accesarse examinando la localidad de
memoria apuntada por FP. La variable v puede accesarse
examinando (FP-wordsize). Algo para aclarar es que la pila
crece de direcciones altas a bajas.
140. En etiqueta B los RA se
mirarían así: u
Activation Record v
for main Saved Registers
b
a
w
x
Activation Record
for bar
Saved Registers
d
c
y FP
Activation Record
z
for foo
Saved Registers
SP
141. (cont.)
La variable y en función foo puede accesarse al
examinar la localidad de memoria apuntada por FP.
La variable z en foo puede accesarse examinando
la localidad (FP-wordsize). El parámetro de entrada
c en función foo puede accesarse examinando la
localidad (FP+wordsize), mientras que el parámetro
d puede accesarse con (FP+2*wordsize).
Algo importante es que la función llamadora es
responsable de poner los parámetros actuales y de
quitarlos cuando acabe la función llamada.
142. Ensamblador Abstracto
El ensamblador abstracto es dividido en árboles de
estatutos y árboles de expresiones. Árboles de
estatutos no tienen valores pero los de expresiones
si lo tienen.
Existen cinco diferentes tipos de árboles de
expresiones:
Constante. Consiste de un valor de la constante (solo
enteros).
Registro. Una cadena que especifica el registro.
Operador. Dos árboles de expresión para los operandos, y
el operador: +,-,*,/,<,>,<=,>=,=,&&,||,!.
143. (cont.)
CallExpression. Contiene una etiqueta de lenguaje ensamblador y
un árbol de expresión por cada uno de los parámetros en la llamada
de la función (function call). La etiqueta es la dirección del inicio de
la función llamada. Por ejemplo la función foo(3,4,5) se
representaría por el árbol de expresión:
CallExpresion(“foo”)
Constant(3) Constant(4) Constant(5)
El ensamblador abstracto en este caso no contiene instrucciones
explícitas para asignar los parámetros actuales a la pila. Cuando
se tradusca el ensamblador abstracto de una callExpression a
ensamblador real, se incluye el código para hacer la copia de los
parámetros actuales a la pila.
144. Memoria. Solo se denota la dirección de memoria.
Ejemplo:
Memory
Constant(1006)
Casi nunca se manejan direcciones absolutas sino que estas son
relativas al FP. Por ejemplo para referirse a la localidad de memoria de
una variable local con un desplazamiento (oofset) de 4 del FP es
representado con el árbol:
Memory
Operator(-)
Register(FP) Constant(4)
145. Existen 8 tipos de árboles de estatutos:
Move. Tienen dos subárboles – el´subárbol izquierdo es el destino del
move, y el derecho es el valor a mover. El izquierdo necesita ser un árbol de
expresión de registro (mover un dato a un registro) o un árbol de expresión
de memoria (mover un dato a memoria). La parte derecha puede ser una
expresión arbitraria.
Label (Etiqueta). Se usan como destinos de “jumps” y “calls”.
Jump. Saltos incondicionales contienen una etiqueta.
Conditional Jump. Contienen un subárbol de expresión y una etiqueta de
lenguaje ensamblador. Si la expresión es verdadera se hace una
transferencia de control a una etiqueta. Si es falsa, entonces habrá un no-
op.
Sequential. Tiene dos subárboles. Representa la ejecución del izquierdo
seguido del derecho.
CallStatement. Contienen una árbol de etiqueta, y un árbol expresión de
cada uno de los parámetros actuales. “Calls” representan “void function
calls”.
Empty. Son “no-ops” y son removidos cuando se traduce a ensamblador
real.
Return. No retorna un valor (como Java). Solo cambia el flujo de control. Un
“return” de Java se implementa incluyendo código que retorne el valor de la
función (en el registro resultado) además del ensamblador abstracto del
“return”.
146. Ejemplos: (asumiremos que wordsize=4 y que un entero se
guarda en una palabra)
void foo(int a, int b) {
int x;
int y;
boolean z;
x = 1;
y = a * b;
y++;
bar(y, x + 1, a);
x = function(y+1, 3);
if (x > 2)
z = true;
else
z = false;
}
153. Creación de Ensamblador Abstracto
Variables
Variables base
void foo() {
int x;
int y;
/* Body of foo */
}
La variable x y y se representarían con los
árboles: Memory
Memory
Operator(-)
Register(FP)
Register(FP) Constant(4)
154. Variables Arreglo: Son almacenadas en el
heap, y usando un apuntador a la base del
arreglo el cual se almacena en la pila.
La siguiente función o método
void foo arrayallocation() {
int x;
int A[];
int B[];
A = new int [5];
B = new int [5];
/* Body of function */
}
155. La variable local x es almacenada en la pila,
igual que la dirección base del arreglo A y del
arreglo B.
Stack Heap
x A[0]
FP
A[1]
A
B A[2]
A[3]
A[4]
Saved Registers
B[0]
B[1]
B[2]
SP
B[3]
B[4]
159. Arreglos Multidimensionales se manejan de
manera similar.
Ejemplo:
void twoDarray {
int i;
int c [ ] [ ] ;
C = new int [3] [ ] ;
for (i=0; i<3; i++)
C[i] = new int[2] ;
/* Body of function */
}
160. El RA y el Heap se ven así:
Stack Heap
FP i C[0]
C C[1]
C[2]
Saved Registers C[0] [0]
C[0] [1]
SP
C[1] [0]
C[1] [1]
C[2] [0]
C[2] [1]
161. La variable C sería representada así:
Memory
Operator(-)
Register(FP) Constant(WORDSIZE)
164. Variables Instanciadas
Son muy similares a las variables arreglo. La única diferencia es
que en las variables arreglo, el desplazamiento para el índice
necesita ser calculado, mientras que en las variables
instanciadas, el desplazamiento es conocido en tiempo de
compilación.
Ejemplo:
class simpleClass {
int x;
int y;
int A[ ];
}
void main() {
simpleClass();
s = new simpleClass();
s.A = new int[3]
/* Body of main */
}
165. ¿Cuál es el árbol de ensamblador
para s.y ?
Él árbol de s: Memory
Register(FP)
Para obtener y de s:
Memory
Operator(-)
Memory Constant(WORDSIZE)
Register(FP)
166. Memory
El árbol de la variable s.x:
Memory
Register(FP)
El árbol para .A[3]: Memory
Operator(-)
Memory Operator(*)
Operator(-) Constant(WORDS Constant(3)
IZE)
Memory Constant(2*WORD
SIZE)
Register(FP)