Crea tu propio compilador – Cap. 15 – Condicionales

15.1 Introducción

En el artículo anterior implementamos las instrucciones print() y println() que nos ayudaron a mostrar texto y el contenido de variables y expresiones por pantalla. Además, aprendimos como agregar funciones del sistema.

Con print() y println() implementadas, hemos dotado de nuestro lenguaje de la posibilidad de mostrar información variada por pantalla. Pero necesitamos de un tipo de sentencias especiales para darle más poder a nuestro lenguaje.

En esta sección veremos cómo podemos implementar las sentencias condicionales, ayudándonos de las macros que nos ofrece MASM32.

15.2 Teoría de Compiladores

Como se comentó en el capítulo anterior, existen diversas técnicas de optimización. A continuación se describen algunas de ellas.

Variables y funciones no usadas

Esta es una de las optimizaciones casi universales que aplican los compiladores y que consiste en no generar código o reservar espacio que no se va a usar dentro de un ejecutable.

Las razones son lógicas:

  • Primero se trata de ahorrar tiempo, CPU y RAM, compilando código que no se va a usar.
  • No queremos construir un ejecutable con código o datos innecesarios que solo incrementarán el tamaño de nuestro ejecutable.
  • Por último, compilar funciones no usadas puede desbordar completamente el tamaño de un ejecutable, haciéndolo inmanejable o hasta imposible de compilar por limitaciones del hardware. Esto puede suceder, por ejemplo, con el uso de librerías que contienen miles de funciones o estructuras, en donde no se espera que se usen todas a la vez.

En compiladores clásicos, como los de C, la detección de funciones o subrutinas no usadas, se suele encargar a la fase de enlazado, que es un proceso que no forma parte del compilado en sí, sino que está a cargo de un programa independiente llamado enlazador o linker. Sin embargo, nada prohíbe hacer la detección de funciones no usadas desde el mismo análisis del código fuente.

La identificación de variables no usadas, no se remite solo a casos como:

var 
  x: integer;
  no_usada: integer;
begin
  x := 1;
end.

En donde resulta evidente que la variable «no_usada», no se usa. Sino que, también, podríamos decidir que la variable «x» tampoco se usa, si solo recibe una asignación pero ninguna lectura, en todo el programa.

Este tipo de optimizaciones requieren un análisis más complejo, que se suele realizar en el árbol de sintaxis o en alguna representación intermedia posterior.

Plegado de constantes

El plegado o plegamiento de constantes, es una de las optimizaciones más comunes y más simples de implementar. Su necesidad es casi cuestión de sentido común. Consideremos el siguiente fragmento de código:

a = 1 + 5

Esta asignación se puede convertir directamente a un código en ensamblador que realice la operación de suma y asignación. Pero es claro que es innecesario realizar la operación «1+5» en ensamblador, sabiendo que esa suma equivale al valor 6 y que es algo que el mismo compilador puede realizar por si mismo. De modo que la operación final a compilar debe ser:

a = 6

A esta simplificación es a la que se llama «Plegar constantes». De esta forma se evita realizar operaciones innecesarias y se genera un código más compacto.

La misma optimización puede realizarse en expresiones con cadenas y también aplica a expresiones que incluyen constantes declaradas previamente, como en el siguiente caso:

cons VALOR = 5
a = 1 + VALOR

Este tipo optimización se suele realizar en alguna representación intermedia como el código de 3 direcciones.

Propagación de constantes

Este tipo de optimización está relacionada con el plegado de constantes y trabajan en simbiosis para obtener mejores resultados.

La optimización consiste en identificar casos de variables que se usan una sola en la inicialización y que, por lo tanto, se pueden reemplazar por una constante. Como ejemplo, analicemos el siguiente código:

x = 1
a = x + 3

Si en este código, la variable «x» es inicializada solo una vez y luego solo se le realizan operaciones de lectura; se puede reemplazar de forma optimizada por el siguiente código equivalente.

a = 4

Esta simplificación puede realizarse en una sola etapa o en dos, incluyendo un plegado de constantes, pero el resultado debe ser siempre una expresión simplificada.

Esta simplificación no aplicaría si es que la variable «x» tuviera algún otro acceso desde fuera del programa o si es que está mapeado en un puerto especial de la CPU o MCU (microcontrolador), como un «Timer» o un puerto I/O.

Para evitar la aplicación de esta optimización, en casos en donde podría resultar en un error, algunos lenguajes, de programación como el C, incluyen la palabra reservada VOLATILE, que permite indicar que ciertas variables pueden cambiar desde fuera y así se les protege de la propagación de constantes.

Existen diversos tipos de optimizaciones adicionales que pueden aplicarse en diversas fases de la compilación. Aquí solo se mencionan 3 para no hacer más larga esta introducción teórica, pero existe amplia bibliografía especializada.

15.3 Condicionales en ensamblador

Las sentencias condicionales son aquellas que nos permiten evaluar una condición del programa, por lo general la evaluación o comparación de una variable para decidir la ejecución, o no, de un bloque del programa.

Figura 15.2 – Estructura de una condicional

Ante todo debemos saber que en el ensamblador del Intel x86, como en la mayoría de ensambladores, no existe algo similar a lo que conocemos como una condicional de los lenguajes de alto nivel.

Lo que nos ofrecen los ensambladores típicos son una serie de instrucciones de comparación, como la instrucción CMP, en nuestra arquitectura. Dicha instrucción compara registros, variables o literales, dejando el resultado de la comparación en algunas banderas del registro FLAGS.

Consideremos el siguiente ejemplo en ensamblador, que nos permite comparar el valor de dos variables:

mov eax, X
cmp eax, Y
jl xlower

Este esquema de comparaciones similar al de varios ensambladores, y consiste en usar una instrucción de comparación o resta, para actualizar el estado de unas banderas de estado y luego usar comparaciones de salto que leen las banderas de estado.

Esta aproximación de comparación nos servirá inicialmente para comparar números.

Llevando el esquema de comparación de la figura 15.1 al ensamblador, tendríamos un código como el que sigue:

<Instrucción de comparación>
<salto condicional a FIN_IF si la comparación da FALSE>
<cuerpo del IF>
<etiqueta FIN_IF>

Este esquema de salta en el caso negativo es típico de las condiciones en ensamblador cuando se quiere ejecutar un bloque de código, porque la mayoría de ensambladores no procesan bloques de código.

Como ya habíamos indicado, por simplicidad del proyecto, no manejaremos el tipo de dato booleano, sino que solo usaremos números para indicar el resultado de una comparación. De todas formas eso no es importante porque solo usaremos expresiones booleanas en las condicionales.

Para aclarar mejor el caso, consideremos la siguiente condición en nuestro lenguaje Titán:

IF valor>1 THEN
  valor = 0;
END;

Para llevar este código a ensamblador haríamos:

    mov eax, valor
    jle fin_if
    mov DWORD PTR valor, 0
fin_if:

Donde se ve que la instrucción de comparación «>» se traduce a la comparación opuesta que JLE (jump if less or equal).

15.4 Implementación en Titan

El uso de instrucciones de salto, es efectivo, pero se complica cuando manejamos condiciones anidadas o condicionales con el caso ELSE.

Por simplicidad, nosotros usaremos una de las macros (.IF) que nos ofrece el MASM32 y que permite crear condiciones y bloques en un estilo cercano a los lenguajes de alto nivel.

La macro .IF del MASM32 es bastante completa y tiene la siguiente estructura:

.if eax == 123
...
.elseif eax !=123
...
.else
...
.endif

Las condiciones pueden ser operaciones con registros, literales o con variables.

Como la sintaxis de las macros es muy similar a la de nuestro lenguaje, la generación de código se simplificará.

En nuestro caso solo usaremos las secciones .IF y .ELSE que son suficientes para implementar nuestro lenguaje, tal cual se definió en el Capítulo 4.

La mayor complejidad para la generación de código viene del hecho de que, en nuestro lenguaje, manejamos dos tipos de datos, además de que nuestros operadores de comparación son ligeramente diferentes a los del MASM32.

Para la implementación, dividiremos el problema en dos partes:

  1. Generación del código de las condiciones (Lo que va entre el IF y el THEN).
  2. Procesamiento de los bloques de código (Lo que va después del THEN y del ELSE).

15.5 Generación del código de las condiciones

Esta parte tiene la función de generar todo el código previo de evaluación de la condición, hasta la primer línea de la macro .IF.

Por simplicidad, limitaremos la complejidad de nuestras condiciones a la forma

<Operando1> <Operador de comparación> <Operando 2>

La limitación será que el operando 2 debe ser un operando simple: Un literal o una variable. Esta limitación proviene del diseño del evaluador de expresiones (como se explicó en el Capítulo 12), y se aplica a todas las expresiones, no solo a las condicionales.

El operador de comparación puede ser cualquiera de los que definimos en nuestro lenguaje Titan: «<«, «>», «<=», «>=», «<>» o «=».

Para implementar la evaluación de las condiciones, usaremos el siguiente flujo:

Figura 15.3 – Evaluación de expresión condicional

Por comodidad, las tareas 3 y 4 se ejecutarán en un solo procedimiento, al que llamaremos Compare() y será el siguiente:

procedure Compare(macro: string; operation: string);
begin
  NextToken();     //Toma token
  GetOperand2();   //Evalúa segundo operando
  if MsjError<>'' then begin exit; end;
  if op1Type<>op2Type then begin
    MsjError := 'No se pueden comparar tipos diferentes';
    exit;
  end;
  //Son del mismo tipo
  if op1Type = 1 then begin  //Comparación de enteros
    asmline(macro + ' ebx ' + operation + ' eax');
  end else if op1Type = 2 then begin  //Comparación de cadenas
    asmline('invoke lstrcmp, addr _strB, addr _strA');
    if operation = '<' then begin
      asmline(macro + ' eax == -1');   // EAX<0 no funciona porque EAX es siempre positivo.
    end else if operation = '>' then begin
      asmline(macro + ' eax == 1');    // EAX>0 no funciona, porque -1 también cumple.
    end else if operation = '<=' then begin
      asmline(macro + ' (eax == -1) || (eax == 0)' );
    end else if operation = '>=' then begin
      asmline(macro + ' (eax == 1) || (eax == 0)');
    end else begin // "=" o "<>"
      asmline(macro + ' eax ' + operation + ' 0');
    end;
  end;
  resType := 1;  //En realidad, el resultado queda en la bandera ZF de la CPU.
end;

En este código también se incluye la compatibilidad de tipos entre los operandos de la condición. Se supone que el Operando 1 ya debe haber sido leído con GetOperand1().

Aquí es donde se escribe la primera línea de la condicional, la que incluye el «.IF». El código generado depende del tipo de operandos de la condición.

Observar que para las cadenas, hacemos una llamada a la función «lstrcmp» que nos devuelve el resultado de la comparación en el registro EAX. Como los valores que se devuelven en EAX, solo pueden ser -1, 0 y 1; es necesario hacer una adaptación de la condición (<, >, <=, >=, <> o =).

Este procedimiento Compare() solo terminan la evaluación de la expresión condicional. La lectura de toda la expresión, incluyendo al Operando 1, se debe hacer desde otro procedimiento, al que llamaremos EvaluateIfExpression():

procedure EvaluateIfExpression();
begin
  //Toma primer operando
  GetOperand1();
  if MsjError<>'' then begin exit; end;
  //Guarda datos del operando
  //Verifica si hay operandos, o la expresión termina aquí
  TrimSpaces();
  //Captura primer operando, asumiendo que es el único
  if srcToktyp = 0 then begin exit; end; //Terminó la línea y la expresión
  if srcToken = 'then' then begin exit; end; //Terminó la expresión
  //Hay más tokens
  //Extrae operador y operando
  if srcToken = '=' then begin
    Compare('.IF', '==');  //Puede salir con error
  end else if srcToken = '<>' then begin
    Compare('.IF', '!=');  //Puede salir con error
  end else if srcToken = '>'  then begin
    Compare('.IF', '>');  //Puede salir con error
  end else if srcToken = '<'  then begin
    Compare('.IF', '<');  //Puede salir con error
  end else if srcToken = '>=' then begin
    Compare('.IF', '>=');  //Puede salir con error
  end else if srcToken = '<=' then begin
    Compare('.IF', '<=');  //Puede salir con error
  end else begin
    MsjError := 'Error de sintaxis: ' + srcToken;
    exit;
  end;
end;

Con este código, que incluye la llamada a Compare(), ya podemos procesar la parte de la condición de las condicionales en Titan, o sea la primera línea.

El código generado tendrá la siguiente forma:

<Código de evaluación del Operando 1>
<Código de evaluación del Operando 2> 
<Llamada a la macro .IF de acuerdo a la condición>

Pero aún falta terminar de procesar la parte de los bloques de la condicional: La que incluye el cuerpo del THEN y del ELSE.

15.6 Procesamiento de los bloques THEN y ELSE.

Como ya podemos leer la condición de un IF de Titan, y solo nos falta procesar los bloques de código; vamos a crear una función adicional, a la que llamaremos processIf(), y tendrá la siguiente definición:

procedure processIf();
begin
  NextToken();  //Pasa del "print"
  EvaluateIfExpression();
  if MsjError<>'' then begin exit; end;
  TrimSpaces();
  if srcToken <> 'then' then begin
      MsjError := 'Se esperaba "then"';
      exit;
  end;
  NextToken();  //Toma el "THEN"
  //Aquí Procesamos el cuerpo de la condicional.
  ProcessBlock();   //Llamada recursiva
  if srcToken = 'end' then begin
    asmline('.ENDIF');
    NextToken();  //Toma el "THEN"
  end else if srcToken = 'else' then begin
    NextToken();
    asmline('.ELSE');
    ProcessBlock();   //Llamada recursiva
    if srcToken <> 'end' then begin
      MsjError := 'Se esperaba "end"';
      exit;
    end;
    asmline('.ENDIF');
    NextToken();  //Toma el "END"
  end else begin
    MsjError := 'Se esperaba "end"';
    exit;
  end;
end;

No hay mucho que describir aquí porque como toda la lógica de saltos de las condicionales la hace la macro .IF; nuestro procesamiento de condicionales solo se remite a leer los bloques y las palabras reservadas ELSE y END para reemplazarlas por sus correspondientes en MASM32, que serían .ELSE Y .ENDIF.

Un detalle notable, que debemos resaltar en este código, es que, para procesar el cuerpo del THEN y del ELSE, estamos haciendo una llamada a ProcessBlock() que es el procedimiento que reconoce a todas las instrucciones implementadas, incluyendo a las mismas condicionales. Esto implica que estamos haciendo uso de la recursividad. Este detalle se debe tener en cuenta cuando intentemos compilar nuestro propio código porque implica que también debemos de dotar a Titan de soporte para recursividad.

15.7 Ajustes finales y pruebas

Para a agregar el procesamiento de las condicionales a nuestro compilador, la primera tarea sería interceptar el procedimiento ProcessBlock() que procesa a las sentencias, donde de momento, solo procesamos asignaciones y la instrucción «print». Allí debemos agregar una condición más:

procedure ProcessBlock;
begin
  while EndOfBlock()<>1 do begin
    if srcToken = 'print' then begin
      processPrint(0);
      if MsjError<>'' then begin break; end;
    end else if srcToken = 'println' then begin
      processPrint(1);
      if MsjError<>'' then begin break; end;
    end else if srcToken = 'if' then begin
      processIf();
      if MsjError<>'' then begin break; end;
    end else if srcToktyp = 2 then begin
      //Es un identificador, debe ser una asignación
      ProcessAssigment();
      if MsjError<>'' then begin break; end;
      ...
  end;
end;

Con eso debería ser suficiente, pero, como estamos haciendo una llamada a ProcessBlock() desde processIf(), es casi seguro que necesitamos agregar una llamada Forward() a ProcessBlock(), en un punto anterior a processIf():

procedure ProcessBlock(); forward;

Con esta instrucción estamos indicando a Pascal que no se preocupe, que asuma que existe un procedimiento llamado ProcessBlock(), pero que lo vamos a implementar más adelante.

Así, finalmente, logramos darle a Titan la potencia como para ejecutar código de forma condicional, en lugar de solo ejecutar instrucciones linealmente.

Hagamos nuestra primera prueba:

Figura 15.4 – Probando una condicional simple

Notar que el código ensamblador reproduce la condicional usando la macro .IF en forma similar a como lo escribimos en Titan.

También podemos incluir condicionales con el caso ELSE o inclusive anidadas:

Figura 15.5 – Condicionales anidadas en Titan

Con las condicionales implementadas, ya tenemos la posibilidad de implementar algunos algoritmos sencillos en nuestro lenguaje de programación.

En la siguiente entrega estaremos implementando soporte para el bucle WHILE.


Sé el primero en comentar

Dejar una contestacion

Tu dirección de correo electrónico no será publicada.


*