4  Análisis Semántico

En la mayoría de los lenguajes de programación de tercera generación existe el concepto de variable, que en última instancia se mapea a una región de la memoria donde se almacena un valor. Una de las reglas básicas del uso de variables en casi todos los lenguajes tiene que ver con la declaración y/o inicialización de una variable antes de su uso. Por ejemplo, en los lenguajes tipo C (C++, C#, Java), tenemos la siguiente construcción:

int x = 5;
int y = 10;
// ....
int z = x + y;

Desde el punto de vista sintáctico, podemos pensar que un fragmento de la gramática que genera este lenguaje será algo como:

<assignment> := <type> <id> "=" <expr> ";" | ...

Una gramática como esta será incapaz de diferenciar situaciones como la anterior, de situaciones como la siguiente:

int x = 5;
int y = 10;
// ....
int z = p + q;

Donde las variables p y q no aparecen anteriormente en ninguna parte del método correspondiente. En la práctica es virtualmente imposible diseñar una gramática que tenga en cuenta que el identificador p tiene que haber sido usado en la parte derecha de una asignación antes de que aparezca en la parte izquierda.

Peor aún es el problema de determinar qué operaciones son válidas para un tipo determinado. Por ejemplo, impedir el uso del operador + entre una variable de tipo int y una de tipo bool. Incluso más complejo es verificar la consistencia de una invocación x.F(). En este caso es necesario saber de qué tipo T es x para determinar si existe un método F declarado en la clase T, o peor aún, en algún padre de la clase T.

Un ejemplo aún más complicado desde el punto de vista sintáctico es validar si en una invocación F(a,b,c) la cantidad de parámetros es correcta, y si los tipos asociados a las expresiones a, b y c son compatibles a los tipos declarados en los parámetros formales de la función (iguales, herederos o existe una conversión implícita). Por ejemplo, distinguir en el fragmento de programa siguiente, que el método G es correcto, pero ni H ni I son correctos:

void F(int a, int b) {
    // ...
}

void G() {
    F(1, 2);
}

void H() {
    F(1, "2");
}

void I() {
    F(1, 2, 3);
}

Podemos pensar que la gramática “natural” para la declaración e invocación de funciones tiene la forma siguiente:

 <func-decl> := <type> <id> "(" <arg-list> ")" "{" <statement-block> "}"
  <arg-list> := <arg> | <arg> "," <arg-list> | epsilon
       <arg> := <type> <id>

<func-invok> := <id> "(" <expr-list> ")"
 <expr-list> := <expr> | <expr> "," <expr-list> | epsilon
      <expr> := ...

En esta gramática (o variantes similares) no existe ninguna diferencia que permita distinguir la invocación hecha en G de la invocación hecha en H. Intuitivamente no podemos expresar en una gramática libre del contexto las dependencias y relaciones entre los tipos, números de argumentos, y ámbitos de variables, dado que estas relaciones son intrínsicamente dependientes del contexto. Esto se debe a que dichas relaciones se ven expresadas, en principio, a todo lo largo del programa. La distancia entre una declaración de variable o función y su uso puede ser arbitrariamente larga. El orden en que las declaraciones y los usos se entrelazan es también arbritario. Por lo tanto, en principio, no deberíamos ser capaces de encontrar gramáticas libres del contexto que nos permitan expresar estas restricciones.

De forma general, el problema de reconocer si una cadena pertenece a cierto lenguaje, podemos verlo como el problema de determinar si dicha cadena cumple una serie de predicados lógicos. Por ejemplo, el lenguaje \(a^n b^n\) está formado por las cadenas \(\omega = s_1 s_2 \ldots s_n\) que cumplen los predicados siguientes:

Para muchas clases de predicados, podemos construir gramáticas que generan los lenguajes correspondientes. A veces, la intersección de estos lenguajes tiene una estructura tal que aún podemos seguir construyendo gramáticas para el lenguaje final. En otras ocasiones, la intersección de varios predicados nos da un lenguaje tal que, aunque somos capaces de reconocer las partes constituyentes, no podemos reconocer el lenguaje como un todo con gramáticas del mismo poder expresivo.

Consideremos entonces un lenguaje de programación determinado para el que queremos construir un compilador. Una de las tareas de este compilador es determinar qué cadenas (programas) son válidas en el lenguaje. Podemos pensar en la gran variedad de predicados que se aplican en estos casos. Por un lado, están todas las reglas que podemos llamar “sintácticas”: los métodos empiezan por un identificador y una lista de argumentos entre paréntesis, las instrucciones terminan en un símbolo “punto y coma (;)”, etc. Por otro lado, tenemos todas estas reglas que no son sintácticas: la consistencia en el uso de los tipos, que cierta función debe devolver un valor por todos los posibles caminos de ejecución, que las variables deben inicializarse antes de usarse en expresiones. Podemos llamar a estas reglas, “semánticas”, porque de cierta forma nos indican cuál es el significado “real” del lenguaje.

Por ejemplo, en la instrucción int x = 5; tenemos por un lado el conjunto de reglas que determinan la forma de la instrucción (primero el tipo, luego un identificador, luego un igual, luego una expresión), y las que determinan el significado de la instrucción (almacenar un valor 5 en la zona de memoria asociada a la variable x). Podemos entonces imaginar que dividimos todos los predicados que definen los programas válidos en dos conjuntos: aquellos para los que podemos construir una gramática que los reconozca, y los que no. Los primeros serán justamente los predicados sintácticos, y los segundos, por analogía, los llamaremos predicados semánticos. Para el primer conjunto, tenemos un mecanismo formal que nos permite describirlos: las gramáticas libres del contexto. Para el segundo conjunto, desarrollaremos en este capítulo otro mecanismo formal similar, que nos permitirá describir de forma unívoca cuál es el “significado” de un programa.

Justamente, la fase de análisis sintáctico se encarga de validar que los predicados sintácticos se cumplen (y además construir un árbol de derivación). La fase de análisis semántico, que comenzaremos a estudiar en este capítulo, se encarga entonces de validar que los predicados semánticos se cumplan (y a su vez construir otras estructuras de datos que veremos más adelante).

Hemos visto que en la fase semántica el poder de las gramáticas libres del contexto es insuficiente. Además, los ejemplos de problemas semánticos que hemos presentado nos muestran la extrema dificultad de expresar estas reglas, incluso con gramáticas dependientes del contexto (para las que además no tenemos mecanismos reconocedores eficientes). Tenemos entonces que cambiar de paradigma.

Recordemos el problema a resolver: verificar los predicados semánticos (que aun no hemos definido completamente), una vez tenemos la seguridad de que la sintaxis es correcta. Recordemos entonces los árboles de derivación, que representan en una estructura computacionalmente cómoda de manipular el conjunto de producciones y el orden en que son aplicadas para producir la oración correspondiente. Este árbol de derivación a menudo se denomina árbol de sintaxis concreta, pues representa exactamente todos los elementos de la sintaxis descritos en la gramática. Intuitivamente, este árbol de derivación contiene todo el contexto del programa, en una estructura conveniente para ser explorada. ¿Y si intentáramos resolver los predicados semánticos, justamente viéndolos como predicados sobre el árbol de derivación, en vez de sobre la cadena de entrada? ¿Dado que tenemos toda cadena representa en una forma que nos expone toda la estructura sintáctica, no debería ser más fácil detectar aquí todos estos predicados que pueden, en principio, depender de toda la estructura global de la cadena?

Veamos como podemos reescribir algunos de estos predicados en forma de predicados sobre el árbol. Por ejemplo, la regla de que toda variable debe haber sido declarada antes de usarse. Pensemos en un programa arbitrario de C, por ejemplo:

int x = 5;
//...
int y = x + 1;

Pudiéramos pensar en un posible árbol de derivación para este programa (para una gramática correcta del lenguaje C), donde habría necesariamente un subárbol con la forma:

Y por otro lado, tendríamos un subárbol con la forma:

Y ambos subárboles serían hijos de algún nodo que representa a la función correspondiente. Entonces podemos pensar en una estrategia para determinar si todas las variables son declaradas antes de su uso. En un recorrido en pre-orden por dicho árbol, podemos ir recolectando todas las declaraciones hechas en cierta estructura de datos, y luego cada vez que nos encontremos una variable referenciada en parte izquierda de una expresión, ¡simplemente consultamos dicha estructura y verificamos la existencia de una variable con dicho nombre, y de paso la consistencia de los tipos! De forma similar podemos pensar en la verificación de la cantidad de argumentos usados en una función y sus tipos.

4.1 Árboles de Sintaxis Abstracta

En general, una vez tenemos el árbol de derivación, tenemos suficiente información sobre la cadena para reconocer cuáles son todas las variables, métodos, invocaciones, los tipos de cada expresión, etc. El árbol de derivación justamente nos asocia cada token de la cadena a su función sintáctica. Nos dice, por ejemplo, que el identificador x es el nombre de una variable, mientras que el identificador printf pudiera ser el nombre de una función. Además, nos dice cuando este identificador x aparece por primera vez, y mejor aún, cuándo aparece en parte derecha o en parte izquierda de una expresión. Tenemos entonces todo el poder expresivo de nuestros algoritmos y estructuras de datos (recorridos de árboles, diccionarios y tablas hash, etc.) para diseñar mecanismos de validación semántica. ¡Justamente es por este motivo que construimos el árbol de derivación en primer lugar!

Sin embargo, antes de que lanzarnos a diseñar algoritmos de validación semántica, revisemos nuestro árbol de derivación, y notaremos que su estructura no es exactamente idónea para esta tarea. Tomemos por ejemplo la siguiente gramática, que genera un lenguaje de expresiones aritméticas simples:

E = T + E | T
T = int * T | int | (E)

Y la cadena siguiente:

2 * (3 + 5)

Como sabemos, esta cadena realmente como secuencia de tokens es:

int * ( int + int )

De momento no nos preocuparemos por el valor concreto del número almacenado en cada token. Como sabemos del análisis lexicográfico, el lexer asocia a cada token además de la clase correspondiente, el fragmento de cadena original que lo forma. Cuando sea conveniente, podemos ver la cadena anterior como:

int{2} * ( int{3} + int{5} )

Donde indicamos explícitamente el lexema. Durante el análisis sińtáctico hemos obviado este detalle pues solo nos interesaban los símbolos que aparecen en la gramática. Más adelante volveremos a incluir en nuestro análisis el valor concreto de cada token, que en última instancia constituyen los datos del programa a compilar.

Por el momento, concentrémonos nuevamente en la cadena a parsear. Esta cadena es generada de forma única por el siguiente árbol de derivación:

Este árbol de derivación efectivamente codifica todas las operaciones necesarias a realizar para evaluar la expresión (que es en última instancia el problema a resolver). Sin embargo, este árbol representa con demasiado detalle la expresión. Supongamos que queremos diseñar una jerarquía de clases para representar este árbol. Dicha jerarquía tendría varias clases innecesarias, como aquellas que representan a los nodos ( y ). Por otro lado, la estructura del árbol es poco eficiente para representar la expresión, pues hay varios nodos que son redundantes. Por ejemplo, el nodo raíz E no nos da ninguna información sobre el tipo concreto de la expresión. De forma general podemos reconocer dos tipos de elementos innecesarios:

  • Nodos que representan elementos sintácticos innecesarios (e.j. los paréntesis)
  • Nodos que derivan en un solo hijo (e.j. E -> T)

Los elementos sintácticos innecesarios, como los paréntesis, se emplean en la gramática para representar la prioridad entre sub-expresiones. Sin embargo, una vez construído el árbol de derivación, la prioridad entre las sub-expresiones queda explícitamente descrita en la propia estructura del árbol. Por otro lado, los nodos que derivan en un solo nodo hijo, tales como E -> T, son necesarios desde el punto de vista de la gramática para resolver las ambigüedades, pero una vez que se construye el árbol de derivación, no aportan ninguna información adicional.

Intentemos eliminar estos elementos innecesarios en el árbol de derivación anterior:

Este árbol representa exactamente la misma expresión aritmética, y quedan explícitamente descritos el orden y el tipo de las operaciones. Una vez llegado a este punto, podemos notar que hay otro elemento innecesario en el árbol. Si nos fijamos con atención, veremos que el nodo asociado a un operador (* o +) siempre estará como hijo de exactamente el mismo tipo de nodo (T o E) respectivamente. Este hecho se desprende directamente de la gramática, pues el terminal * solo se genera por T y el terminal + solo se genera por E. Por tanto, ambos nodos respectivos (T y * o E y +) siempre aparecerán juntos, y por tanto es redundante tener ambos.

Pensemos ahora en la jerarquía de clases que representa este árbol, y un posible algoritmo recursivo de evaluación. Una vez en el nodo T, evaluados recursivamente las expresiones izquierda y derecha, ¿qué operación es necesario aplicar? Evidentemente, dependerá del nodo que representa la operación. Pero este nodo (* o +) siempre es un terminal, por lo tanto, nunca será necesario “bajar” recursivamente para descubrir que tipo de operación hay que hacer en un nodo T o E. Podemos entonces conformarnos con un árbol donde la operación a realizar esté explícita en el nodo padre (T o E) y no como un hijo adicional:

Intuitivamente, el árbol anterior es capaz de representar con todo el detalle necesario la semántica de la expresión a evaluar, y no tiene ningún elemento innecesario.

A este tipo de estructura se le denomina árbol de sintaxis abstracta (AST), precisamente porque representa solamente la porción de sintaxis necesaria para evaluar la expresión o programa reconocido. En el AST solamente existen nodos por cada tipo de elemento semántico diferente, es decir, por cada significado distinto.

Por ejemplo, en nuestro lenguaje de expresiones aritméticas, existen tres tipos de “entidades” o conceptos diferentes:

  • un número,
  • una operación de suma, y
  • una operación de multiplicación.

De modo que si el árbol de sintaxis concreta (o árbol de derivación) tiene un tipo de nodo por cada tipo de función sintáctica diferente, entonces un árbol de sintaxis abstracta tiene un tipo de nodo por cada tipo de función semántica distina. Las diferentes funciones sintácticas se obtienen directamente de la gramática, y por tanto están muy influenciadas por el tipo de parser que se use. Las funciones semánticas, por otro lado, se diseñan a partir de la funcionalidad que se quiere obtener en el lenguaje, por lo que se tiene generalmente mayor libertad creativa.

4.2 Diseño de un AST

A modo de ejemplo, vamos a definir un lenguaje muy sencillo, para el que diseñaremos un árbol de sintaxis abstracta. Este lenguaje será sobre el dominio de las expresiones aritméticas. Nos permitirá operar con variables y funciones predefinidas, así como definir nuevas funciones. Comenzaremos por listar de manera informal las características que queremos obtener de este lenguaje, y veremos luego como formalizar cada una.

Veamos primero las reglas sintácticas de HULK para let, function y print:

  • let <var>[:<type>]=<init> in <body> define una variable y la usa en una expresión cuerpo
  • function <func>(<arg1>[:<type>], ...):<type> -> <expr> define una función
  • print(<expr>) es una función predefinida que imprime valores

Veamos la gramática de HULK para estos constructos:

    <program> := <def-list> <expr>
    <def-list> := <def> ";"
                | <def> ";" <def-list>
                | epsilon
       <def> := "function" ID "(" <param-list> ")" [":" <type>] "->" <expr>
  <param-list> := <param>
                | <param> "," <param-list>
                | epsilon
      <param> := ID [":" <type>]
      <expr> := <let-expr>
              | <func-call>
              | <binary-expr>
              | <atom>
  <let-expr> := "let" ID [":" <type>] "=" <expr> "in" <expr>
  <func-call> := ID "(" <expr-list> ")"
   <expr-list> := <expr>
                | <expr> "," <expr-list>
<binary-expr> := <expr> <bin-op> <expr>
     <bin-op> := "+" | "-" | "*" | "/" | "==" | "!=" | "<" | ">" | ...
       <atom> := NUMBER | STRING | BOOLEAN | ID | "(" <expr> ")"

La gramática anterior es bastante “natural”, en el sentido de que no contiene reglas “extrañas” para resolver, por ejemplo, los problemas de ambigüedad típicos de las gramáticas LL(1). Este es el tipo de gramáticas que usualmente queremos diseñar para transmitir a otros lectores las reglas sintácticas del lenguaje. Luego, para una implementación concreta de un parser, es posible (y altamente probable), que tengamos que redefinir la gramática añadiendo producciones para convertirla en LL o LR, según el caso. En cualquier modo, como ya sabemos resolver ese problema, nos conformaremos con pensar que alguien nos dará un parser para esta gramática.

Definamos ahora informalmente las reglas semánticas de HULK:

  • Una variable solo puede ser definida una vez en cada ámbito (expresión let).
  • Las variables definidas en un ámbito “ocultan” las variables de ámbitos exteriores con el mismo nombre.
  • Toda variable tiene que haber sido definida antes de ser usada en una expresión.
  • Los nombres de funciones son globales y no pueden coincidir con las funciones predefinidas (print, read, etc.).
  • No se pueden redefinir las funciones predefinidas.
  • Una función puede tener distintas definiciones siempre que tengan distinta cantidad de argumentos.
  • Los argumentos de una función deben tener nombres únicos dentro de esa función.
  • Toda función usada debe haber sido definida antes de su invocación (salvo las predefinidas).
  • El cuerpo de una función puede usar los argumentos y las variables definidas en ámbitos exteriores ( closures).

Como vemos, todas estas reglas son de naturaleza dependiente del contexto, pues su alcance puede ser en principio todo el programa. Por lo tanto, no tenemos forma de expresar estas reglas en una gramática libre del contexto. Sin embargo, una vez tengamos el árbol de sintaxis abstracta de un programa concreto, veremos que es relativamente fácil verificar cada una de estas reglas.

Para diseñar el AST de HULK, pensemos en las diferentes funciones semánticas del lenguaje:

  • Un programa es una lista de definiciones de funciones seguida de una expresión.
  • La expresión let es una expresión que crea un nuevo ámbito.
  • print(x) es una llamada a función (expresión).
  • function f(a, b) -> expr es una definición de función.

Comencemos por la jerarquía de clases:

class Node(ABC):
    pass

class Program(Node):
    def __init__(self):
        self.definitions: list[DefFunc] = []
        self.body: Expression = None

class DefFunc(Node):
    def __init__(self):
        self.name: str = ""
        self.parameters: list[str] = []
        self.body: Expression = None

class Expression(Node, ABC):
    pass

Notar que Program contiene directamente las definiciones (no hay una clase Statement intermediaria, ya que en HULK solo tenemos definiciones de funciones como construcciones de alto nivel). La expresión let es ahora una expresión que crea un nuevo ámbito:

class LetIn(Expression):
    def __init__(self):
        self.variable: str = ""
        self.init: Expression = None
        self.body: Expression = None

El let en HULK es una expresión: let x = 5 in x + 1, donde x está disponible solo dentro de body.

Las expresiones binarias las manejamos con una única clase:

from enum import Enum

class Operator(Enum):
    Add = 0
    Sub = 1
    Mult = 2
    Div = 3

class BinaryExpr(Expression):
    def __init__(self):
        self.op: Operator = Operator.Add
        self.left: Expression = None
        self.right: Expression = None

Y los tres tipos de expresiones atómicas:

class FuncCall(Expression):
    def __init__(self):
        self.name: str = ""
        self.args: list[Expression] = []

class Variable(Expression):
    def __init__(self):
        self.name: str = ""

class Number(Expression):
    def __init__(self):
        self.value: str = ""

En HULK, print(x) es simplemente una FuncCall con nombre "print", igual que cualquier otra función definida por el usuario. Esto simplifica el AST ya que no necesitamos una clase especial para print.

Nos queda pendiente la tarea de obtener el AST a partir del árbol de derivación (que es la salida que realmente nos da el parser). De momento vamos a asumir que este algoritmo existe, y nos concentraremos en los tipos de análisis que podemos hacer sobre el AST una vez construido. Más adelante veremos un mecanismo formal que nos permite expresar como se construye el AST a partir del árbol de derivación, y un algoritmo para ello.

4.3 Validando las reglas semánticas

Volvamos a la tarea que dio origen a toda esta discusión del AST: validar el cumplimiento de las reglas semánticas de HULK. Recordemos las reglas:

  • Una variable solo puede ser definida una vez en cada ámbito.
  • Toda variable tiene que haber sido definida antes de ser usada.
  • Los argumentos de una función deben ser únicos.
  • Toda función usada debe haber sido definida antes.

En esta sección presentamos una solución ad-hoc para estos problemas en HULK.

De manera general, el enfoque que usaremos será el siguiente. Cada regla semántica se aplica solo a algunos tipos de nodos. Vamos a definir un método validate() en cada nodo, que nos dirá si dicho nodo es correcto.

La mayoría de las reglas semánticas nos hablan sobre definiciones y uso de variables y funciones. Necesitamos acceso a un “contexto” donde estén definidas las funciones y variables. Diseñemos una estructura de datos que nos represente dicho contexto:

class IContext(ABC):
    @abstractmethod
    def is_defined(self, variable: str) -> bool:
        ...

    @abstractmethod
    def is_function(self, name: str, param_count: int) -> bool:
        ...

    @abstractmethod
    def define_variable(self, name: str) -> bool:
        ...

    @abstractmethod
    def define_function(self, name: str, param_count: int) -> bool:
        ...

    @abstractmethod
    def create_child_context(self) -> "IContext":
        ...

Agregamos el método de validación a la clase base:

class Node(ABC):
    @abstractmethod
    def validate(self, context: IContext) -> bool:
        ...

Veamos la implementación en los nodos concretos. En Program, primero validamos todas las definiciones de funciones (sin aún ejecutarlas), y luego validamos el cuerpo del programa:

class Program(Node):
    def __init__(self):
        self.definitions: list[DefFunc] = []
        self.body: Expression = None

    def validate(self, context: IContext) -> bool:
        for func in self.definitions:
            if not func.validate(context):
                return False
        return self.body.validate(context)

Para DefFunc, necesitamos validar que no exista una función con el mismo nombre y cantidad de parámetros, y luego validar el cuerpo de la función:

class DefFunc(Node):
    def __init__(self):
        self.name: str = ""
        self.parameters: list[str] = []
        self.body: Expression = None

    def validate(self, context: IContext) -> bool:
        if not context.define_function(self.name, len(self.parameters)):
            return False

        inner_context = context.create_child_context()
        for param in self.parameters:
            inner_context.define_variable(param)

        return self.body.validate(inner_context)

Para las expresiones binarias, validamos recursivamente ambos lados:

class BinaryExpr(Expression):
    def __init__(self):
        self.op: Operator = Operator.Add
        self.left: Expression = None
        self.right: Expression = None

    def validate(self, context: IContext) -> bool:
        return self.left.validate(context) and self.right.validate(context)

La expresión let crea un nuevo ámbito. Primero validamos la expresión inicial (init) en el contexto actual, luego creamos un ámbito hijo, definimos la variable, y validamos el cuerpo:

class LetIn(Expression):
    def __init__(self):
        self.variable: str = ""
        self.init: Expression = None
        self.body: Expression = None

    def validate(self, context: IContext) -> bool:
        if not self.init.validate(context):
            return False

        inner_context = context.create_child_context()
        if not inner_context.define_variable(self.variable):
            return False

        return self.body.validate(inner_context)

Para una variable, verificamos que esté definida en el contexto:

class Variable(Expression):
    def __init__(self):
        self.name: str = ""

    def validate(self, context: IContext) -> bool:
        return context.is_defined(self.name)

Para una llamada a función, validamos los argumentos y verificamos que la función esté definida:

class FuncCall(Expression):
    def __init__(self):
        self.name: str = ""
        self.args: list[Expression] = []

    def validate(self, context: IContext) -> bool:
        for arg in self.args:
            if not arg.validate(context):
                return False
        return context.is_function(self.name, len(self.args))

Para Number, no hay reglas semánticas que verificar:

class Number(Expression):
    def __init__(self):
        self.value: str = ""

    def validate(self, context: IContext) -> bool:
        return True

Veamos la implementación del contexto con ámbitos:

class Context(IContext):
    def __init__(self, parent: "Context" = None):
        self.parent = parent
        self.variables: set[str] = set()
        self.functions: dict[str, set[int]] = {}

    def is_defined(self, variable: str) -> bool:
        if variable in self.variables:
            return True
        if self.parent:
            return self.parent.is_defined(variable)
        return False

    def is_function(self, name: str, param_count: int) -> bool:
        if name in self.functions and param_count in self.functions[name]:
            return True
        if self.parent:
            return self.parent.is_function(name, param_count)
        return False

    def define_variable(self, name: str) -> bool:
        if name in self.variables:
            return False
        self.variables.add(name)
        return True

    def define_function(self, name: str, param_count: int) -> bool:
        if name not in self.functions:
            self.functions[name] = set()
        if param_count in self.functions[name]:
            return False
        self.functions[name].add(param_count)
        return True

    def create_child_context(self) -> "Context":
        return Context(parent=self)

Nuestra implementación de contexto nos garantiza que no es posible definir la misma variable o la misma función (con igual cantidad de argumentos) en un mismo ámbito, pero sí nos permite sobreescribir símbolos en ámbitos hijos. Varios autores llaman a esta estructura tabla de símbolos. Nosotros le llamamos contexto, pues da la idea de que en esta estructura se almacena la información dependiente del contexto para cada elemento semántico del programa.