Site hosted by Angelfire.com: Build your free website today!


CAPITULO #5

Semántica y Análisis de Tipos.

Un compilador no solo tiene que revisar la corrección sintáctica del código fuente, si no también si la semántica corresponde a la del lenguaje de programación. Esto significa que el análisis semántica debe garantizar que se consideren todas las reglas dependientes del contexto del lenguaje de programación.

Las tablas de símbolos se emplean para revisar si un identificador ya ha sido declarado, y su uso pueden considerarse como parte del proceso de análisis semántico y el análisis semántico puede efectuarse en paralelo con el análisis sintáctico.

El análisis semántico de un compilador usa la información del análisis sintáctico en cambio con las reglas semánticas del lenguaje de programación para generar una representación interna del código fuente que se va a compilar. Esta generación de una representación del código fuente y portan, que el compilador analice semánticamente las estructuras sintácticas del código fuente al ser reconocidas por el analizador léxico y el analizador sintáctico. La representación interna es un código intermedio que será enviado al generador de código.

5.1 Código intermedio.

Nos referimos a una estructura de código cuya complejidad esta entre el código fuente en lenguaje de alto nivel y el código maquina; los códigos intermedios se pueden considerar como una interfaz entre el generador de código y las fases previas el compilador. El uso de este nivel de maquina abstracta tuene las siguientes ventajas:

  • El propio compilador es independiente de la maquina destino y por tanto es mas transportable, ya que todas las características del hardware destino están consideradas en el genero de código.
  • Es mas fácil llevar a cabo algunas estrategias de optimización, en el código intermedio que en el código fuente.

Sin embargo, el código que puede generarse a partir del código intermedio por lo general será meno eficiente que el código de maquina generado directamente, debido al nivel de traducción adicional.

Notación postfija

La notación postfija es una notación muy simple en la cual se colca un operador en el extremo derecho de una expresión, es decir, después de los operadores, en vez de estar entre ellos. Por ejemplo:

x + y – x* y

en notación postfija es

x y + x y* -,

ya que x y + representa la expresión infija x + y y xy* representa x * y, etc.

Por lo regular, la notación postfija se emplea en maquinas de pilas, ya que puede manejarse con gran facilidad usando una pula. Al analizar una notación postfija de izq., a der., cada vez que se detecta un operador se mete en la pila. La ocurrencia de un operador con m operadores significa que el n – esimo operando estará en la cima de la pila. Después se sacan los operandos de la pila y se mete el resultado de la operación.

Código de Tres Direcciones.

El código más popular es el código de tres direcciones. La forma representativa del código des direcciones es:

res := arg1 op arg2

donde res, arg1 y arg2 pueden ser constantes (solo arg1 y arg2), o identificadores definidos por el programador, o varias auxiliares definidas por el compilador, mientras que op es un operador arbitrario. Las variables auxiliares definidas por el compilador sirve para manejar expresiones mas complejas que contiene mas de un operador; en otras palabras las expresiones con operadores múltiples se dividen en una secuencia de expresiones que contiene solo un operador y que operan con variables auxiliares.



Si consideramos la forma representativa de del código de tres direcciones antes presentada podemos distinguir cuatro elementos básicos: op, arg1, arg2 y res, que suelen representarse como sigue:

Donde por lo regular ar1, arg2 y res son apuntadores a entradas de la tabla de símbolos. En ese caso, esta forma se denomina cuádruplo.

El código de tres direcciones, al igual que el de dos direcciones que veremos mas adelante, permite que la traducción a código para maquinas de registro sea muy simple ya que los operadores muchas veces son el resultado de operaciones anteriores.

De esta manera, las referencias a los resultados de las operaciones previas se dan explícitamente mientras que en el código posfijo las referencias las da la pila en forma implícita.

Código de dos direcciones

Las variables auxiliares en los códigos de tres direcciones se usan para almacenar temporalmente resultados necesarias para proposiciones subsecuentes. La idea es evitar el uso de estas variables auxiliares haciendo referencia a la proposición cuyo resultado seria la variable auxiliar como operando. Por lo tanto, en el campo de resultado del código de direcciones ya no es necesario y los campos arg1 y arg2 pueden contener apuntadores a la tabla de símbolos y apuntadores a la secuencia de proposiciones de dos direcciones. Entonces la forma general del código de dos direcciones es la siguiente:

 

En forma también se conoce como triple.

El ejemplo anterior, z := x ++ y – x* y, se transforma en el siguiente codigo de dos direcciones:

  1. ADD x y
  2. MUL x y
  3. SUB (1) (2)
  4. STO (3) z

Donde los números entre paréntesis representan apuntadores a la secuencia de proposiciones de dos direcciones.

5.2 Traducción diagrama por la sintaxis.

El análisis semántico puede efectuarse en paralelo con el análisis sintáctico; es decir, el análisis semántico cada vez que el analizador sintáctico reconoce una estructura sintáctica, o sea, una producción.

Esta acciones semánticas generan código intermedio cada vez que se ejecutan. Así, la estructura sintáctica del código fuente dirige la traducción a código objeto o código intermedio, por lo cual hablamos de traducción dirigida por la sintaxis.

Acciones semánticas.

Las acciones semánticas se asocian con las producciones de una gramática independiente del contexto. Pueden considerarse como un fragmento de código que se ejecutara cuando el analizador sintáctico reconozca la producción apropiada.

Por ejemplo: consideremos la producción G0.

TERMINO ® TERMINO FACTO (*A*)

Que encontramos que A es la acción semántica asociada. Entonces, si trata de un:

  • Analizador sintáctico ascendente.
  • Analizador sintáctico descendente.

Traducción a cuádruplos.

La generación del código de tres direcciones (cuádruplos) se ilustra en el siguiente ejemplo, donde se considera la gramática G0 y el análisis por desplazamiento y reducción de la frase x + y –x * y. Los fragmentos de código que representan acciones semánticas suponen una matriz "infinita" de variables auxiliares (va[k]), para simplificar las cosas. Por lo tanto, se incrementa una variable k cada vez que se usa una nueva variable auxiliar. ApunT y ApunF son apuntadores al lugar donde puede hallar el valor de una expresión, un termino etc. Los subíndices sirven para distinguir varias ocurrencias de símbolos no terminales. El procedimiento GenC produce un cuádruplo de acuerdo con su parámetros.

 

Por lo tanto, la traducción de la frase x + y – x y a notación postfija es

LOD x LOD y ADD LOD x LOD y MUL SUB.

Lo cual corresponde al ejemplo dado en la sección anterior.

Este esquema de traducción se puede emplear no sólo para expresiones aritméticas, sino para todas lasa estructuras de un lenguaje de programación. Para ejemplificar esto, mostramos la traducción a notación postfija de las proposiciones while e if de PL/O. Las proposiciones while de PL/O se presenta el la siguiente forma:

WHILE CONDICION DO PROPOSICIÓN.

La traducción de las proposiciones condicionales y repetitivas genera instrucciones de salto. Puesto que por lo regular no se conoce la dirección destino cuando se general la instrucción de salto, es necesario completar esta información más tarde. Esto se puede hacer con un segundo paso, en el cual se fijan todas la direcciones abiertas, o con una matriz que contiene el código generado y al que se puede tener acceso directo a través del índice de la instrucción de salto previamente almacenado, para fijar la dirección destino tan pronto como esta se conozca. ( Desde luego, el uso de una matriz de código presenta la desventaja de que el programador del compilador tiene que determinar por anticipado el tamaño máximo del código. Otra alternativa será le uso de etiquetas simbólicas. ) Entonces, la estructura general del código para proporciones while es:

L1

Instrucciones para evaluar CONDICON

JPC L2

Instrucciones para ejecutar PROPOSICIN

JMP L1

L2:

...

donde JPC y JMP son las instrucciones de salto condicional e incondicional, respectivamente. En la figura 5.2 se exhibe un fragmento de código rudimentario para la generación del código postfijo de proposiciones while de PL/O.

Una proposición repetitiva requiere dos instrucciones de salto, pero la proposición if, tal y como existe en PL/O, solo requiere una. Las proposiciones if de PL/O se expresan de la sigu9ente forma:

IF CONDICION THEN PROPOSICIÓN.

DE forma análoga a la estructura general de código que acabamos de ver para las proposiciones while, podemos presentar la estructura general de las proposiciones if:

Código : ARRAY ( [ 0.. codigomax ] OF RECORD.

Co : codigo_op;

Nivel : CARDINAL;

Direct : CARDINAL;

END;

(* código: contiene el código intermedio generado *)

(* co: código de operación; nivel: novel de declaración *)

(* direc: dirección de desplazamiento*)

...

IF símbolo = símwhile THEN

Fijar1 ; = índice; ( * índice: índice de código *)

Leer _ símbolo;

Condición ( ... ); (* analiza las expresiones condicionales *)

Fijar2 ; = índicec;

GenC ( JPC, 0, 0); (* ge era el código para un salto condicional *)

(* donde la dirección no está definida *)

IF símbolo = simdo THEN

Leer_simbolo

ELSE

Error ( … )

END;

Proposición ( .. ]); 8* analiza proposiciones ejecutables *)

GenC ( JMP, 0, fijar1 ) ; ( * genera código para un salto *)

( * incondicional, direc: fijar1 *)

código [ fijar2 ] direc := índicec;

(* fijar dirección JPC *).

END;

 

Figura 5.2 Traducción de proposiciones while.

Instrucciones para evaluar CONDICION.

JPC L1

Instrucciones para ejecutar PROPOSICON

L1:

...

Por ultimo, en la figura 5.3 se muestra un fragmento de código para la generación de código postfijo para las proposiciones if de PL/O ( una vez más utilizamos una matriz de código ).

IF símbolo = simif THEN

Leer_simbolo;

Condición;

If símbolo = simthen

Leer_símbolo

ELSE Error ( ... ) END;

Fijar1 := índice (* almacenar la dirección fijada*)

Gen C ( JPC, 0,0 ); (* genera código parea un salto condicional *)

(* donde la dirección no está definida *)

Proposición;

Código [ fijar1 ]. Direc := índicec;

(* ajustar dirección de salto *)

END;

FIGURA 5.3 Traducción de proposiciones if.

5.3 Comprobación de tipos.

La comprobación de tipos es una forma de asegurar que los identificadores relacionados sean de tipos compatibles. Dos identificadores se relacionan:

  • Al formar el lado izquierdo y derecho de un operador.
  • Al formar el lado izquierdo y derecho de una proposición de asignación;
  • Al ser parámetros reales y formales.

Las comprobaciones de consistencia que se efectúan antes dela ejecución del programa fuente ( es decir, que lleva a cabo el compilador ) se denominan comprobaciones estáticas, mientras que aquellas realizadas ( o comprobaciones durante la ejecución ). La revisión de la sintaxis es un ejemplo de comprobación estática, mientras que la comprobación de tipos es un ejemplo de comprobación que con frecuencia puede efectuarse en forma estática y que en ocasiones debe realizarse dinámicamente:

Por ejemplo, consideremos la siguiente declaración:

Str: ARRAY [ 0...80 ] OF CHAR;

I : INTEGER;

En general, no puede garantizarse estáticamente que se cumpla la condición 0 £ i £ 80 al usar str [ i ]; es decir, esta comprobación debe efectuarse en forma dinámica. Sin embargo, es posible, por ejemplo, comprobar estáticamente si la asignación

Str [ i ] := ch ;

Está permitida, o sea, si ambos lados de la proposición de asignación son de tipos compatibles. Para ello es necesario consultar la tabla de símbolos.

A continuación se presenta un breve ejemplo de cómo introducir las reglas que permiten la comprobación de tipos de expresiones aritméticas. TipoE, TipoT y TipoF representan el tipo de una expresión, términos, etx.; se emplean subíndices para distinguir entre varias ocurrencias de un mismo símbolo no terminal. Se usara el siguiente subconjunto de la gramática G0 con las reglas correspondientes ( suponiendo la semántica de PL/O ) :

E T (*TipoE:= TipoT*)

E1 E2 + T (*TipoE1 := if TipoE2 = integer and

TipoT = integer then integer

Else error_tipo;*)

T F (*TipoT := TipoF*)

T1 T2*F (*TipoT1 := if TipoT2 = integer and

TipoF = integer then integer

Else error_tipo; *)

F x (*TipoF := buscar_Tipo(x); *)

F y (*TipoF := buscar_Tipo(y); *)

F ( E ) (*TipoF := TipoE; *)

El procedimiento buscar_Tipo(...) se usa para determinar el tipo de un identificador por medio de la revisión de la tabla de símbolos. Por ejemplo, el tipo de una expresión formada por aplicar el operador "+" a una sub expresión y un termino es entero si el tipo de la sub expresión y el termino es entero; en caso contrario será un error tipo.

Ahora bien, la formulación d reglas para semánticas ( tipo PASCAL ) que establecen, por ejemplo, que entero* real genera un resultado de tipo real, es obvia y puede verse como se propagara un tipo. Así, durante el proceso de análisis semántico, se puede determinar donde se requieren cambios forzados de tipo ( coerción ) y que operadores deben seleccionarse ( por ejemplo, multiplicación entera o de punto flotante ).

Debemos señalar que n todos los lenguajes de programación permiten una comprobación estática de tipos como la que acabamos de exponer. En el lenguaje de programación APL, por ejemplo, el valor actual de una variable determina el tipo de la variable. Esto significa que el tipo de una variable puede cambiar durante la ejecución del programa y, por lo tanto, debe comprobarse durante la ejecución.

5.4 Generación de código intermedio para PL/O.

Ahora presentaremos un rudimentario segmento de código que es una expresión del analizador sintáctico descendente recursivo del capitulo 4.3. Tiene la misma estructura que el analizador sintáctico descendente recursivo e indica cuando se ejecutan acciones semánticas para generar el código intermedio. El código intermedio está en notación postfija y usa el siguiente conjunto de instrucciones:

LIT cargar un numero ( literal ) n en la pila;

LOD cargar la variable en la pila;

STO almacenar variables de acuerdo con proposiciones de asignación;

CAL llamada a procedimiento;

INT incrementar apuntador de pila para asignación de memoria:

JMP salto incondicional;

JPC salto condicional;

ADD sumar dos operandos de la pila;

SUB restar dos operandos de la pila;

MUL multiplicar dos operandos de la pila;

DIV dividir dos operandos de la pila;

NEG negar la cima de la pila;

OD ¿ es impar la cima de la pila ?

EQ ¿ son iguales los dos elementos superiores de la pila ?

NE ¿ son distintos los dos elementos superiores de la pila ?

LE ¿ es menor o igual la cima de la pila que ( cima de pila – 1) ?

GE ¿ es mayor o igual la cima de la pila que ( cima de pila – 1) ?

LS ¿ es menor la cima de la pila que ( cima de pila – 1 ) ?

GT ¿ es mayor la cima de la pila que ( cima de pila – 1 ) ?

PROGRAM Compilador;

... ... ...

PROCEDURE Dispersión ( vn : alfa ): INTEGER.

BEGIN

...

END Dispersión;

PROCEDURE InsertarTS ( vn: alfa; k: objeto );

BEGIN

END InsertarTS;

Procedure BuscarTS ( VAR p: Tabsim_Disp; vn: alfa ): INTEGER

BEGIN.

END BuscarTS;

PROCEDURE GencC ( o: código_op; n: INTEGER; d: INTEGER );

BEGIN

IF indice > codigomax THEN Error(...); END;

WITH código [indice] DO

Co := o;

Nivel := n;

Direc := d;

END;

INC ( indicec)

End genC;

PROCEDURE Bloque ( niv: INTEGER);

PROCEDURE Declaración_const ;

BEGIN

...

END Declaración_const;

PROCEDURE Declaración_var;

BEGIN

...

END Declaración_var;

PROCEDURE expresión;

VAR ADDoSUB: símbolos;

PROCEDURE Término;

VAR MULoDIV: símbolos;

PROCEDURE Factor;

VAR i: INTEGER ;

BEGIN ( * Factor* )

CASE símbolo OF

Ident : i := BuscarTS ( ElementoD, id);

IF I = 0 THEN Error ( …)

ELSE

With ElementoD DO

CASE tipo OF

Objconst: GenC(LIT, 0 val);

| objvar : GenC(LOD, niv-nivel, dir)

| objproc: Error (...);

END

END

END

Leer_Símbolo;

| número: GenC(LIT, 0, núm); Leer_Símbolo;

| pareni: Leer_Símbolo; Expresión;

IF símbolo = parend THEN Leer_Símbolo

ELSE Error(…) END

ELSE Error(…)

END;

END Factor;

BEGIN (*Término*)

Factor;

WHILE símbolo IN [por, diagonal] DO

MULoDIV:= símbolo; Leer_Símbolo; Factor;

IF MULoDIV= por THEN

GenC(MUL, 0, 0)

ELSE

GenC(DIV, 0, 0)

END;

END

END Término;

BEGIN (*Expresión*)

IF símbolo IN [más, menos] THEN

ADDoSUB := símbolo; Leer_símbolo; Término;

IF.ADDoSUB = menos THEN GenC(NEG, 0, 0) END;

ELSE Término END;

WHILE símbolo IN [más, menos] DO

ADDoSUB := símbolo; Leer_símbolo; Término;

IF ADDoSUB = menos THEN

GenC(SUB, 0, 0)

ELSE

GenC(ADD, 0, 0)

END

END

END Expresión;

PROCEDURE Condición;

VAR opcomp : símbolos;

BEGIN

IF símbolo = símimpar THEN

Leer_Símbolo; Expresión; GenC(OD, 0, 0);

ELSE

Expresión;

IF símbolo IN [igual, noigual, menor, menorig, mayor, mayorig] THEN

opcomp := símbolo; Leer_Símbolo; Expresión;

CASE opcomp OF

igual : GenC (EQ, 0, 0);

| noigual : GenC (NE, 0, 0);

| menor : GenC (LS, 0, 0);

| mayorig : GenC (GE,0, 0);

| mayor : GenC (GT, 0, 0);

| menorig : GenC (LE, 0, 0);

END

ELSE Error(...) END;

END

END Condición;

PROCEDURE Proposición;

VAR i fijar1, fijar2 : INTEGER;

BEGIN

CASE símbolo OF

ident : i := BuscarTS(ElementoD, id) ;

IF i = 0 THEN Error(…)

ELSIF ElementoD.tipo <> objvar THEN

Error(…) END;

Leer_Símbolo;

IF símbolo = cambia THEN Leer_Símbolo

ELSE Error(...) END;

Expresión;

WHIT ElementoD DO

GenC(STO, niv-nivel, dir)

END;

| símcall : Leer_Símbolo;

IF símbolo = ident THEN

i := BuscarTS(ElementoD, id);

IF i = 0 THEN Error(…)

ELSE

WHIT ElementoD DO

IF tipo = objproc THEN

GenC(CAL, niv-nivel, dir)

ELSE Error(...) END;

END;

Leer_Símbolo

END

ELSE Error(…) END;