Análisis semántico

"Un programa sintácticamente correcto puede ser completamente absurdo. Sumar una cadena de texto con un número no tiene sentido aunque la gramática lo permita."

Qué vas a aprender en este capítulo

El análisis semántico verifica que el programa tenga significado — no solo estructura. Después del parser tenemos el AST; ahora debemos verificar que las variables estén declaradas antes de usarlas, que los tipos sean compatibles, y que las funciones existan. Construimos la tabla de símbolos y anotamos el AST.


4.1 Tabla de símbolos

💡 Intuición

La tabla de símbolos es como el registro civil del compilador: lleva un registro de todo lo que existe — variables, funciones, tipos — y en qué ámbito (scope) viven.

Cuando el compilador ve precio <- precio * 1.13, necesita saber: ¿precio fue declarada? ¿Qué tipo tiene? ¿Puedo multiplicarla por 1.13?

📐 Fundamento

Tabla de símbolos con scoping léxico:

from typing import Optional, Dict, Any

class Simbolo:
    def __init__(self, nombre: str, tipo: str, categoria: str = 'variable'):
        self.nombre = nombre
        self.tipo = tipo          # 'entero', 'decimal', 'string', 'funcion'
        self.categoria = categoria
        self.offset = 0           # para generación de código

class Scope:
    """Un ámbito léxico con su propia tabla de símbolos."""
    def __init__(self, nombre: str, padre: Optional['Scope'] = None):
        self.nombre = nombre
        self.padre = padre
        self.simbolos: Dict[str, Simbolo] = {}
    
    def definir(self, simbolo: Simbolo):
        if simbolo.nombre in self.simbolos:
            raise SemanticError(f"'{simbolo.nombre}' ya está declarado en este ámbito")
        self.simbolos[simbolo.nombre] = simbolo
    
    def resolver(self, nombre: str) -> Optional[Simbolo]:
        """Buscar el símbolo en este scope y en los ancestros."""
        if nombre in self.simbolos:
            return self.simbolos[nombre]
        if self.padre:
            return self.padre.resolver(nombre)
        return None

class TablaSímbolos:
    def __init__(self):
        self.scope_global = Scope('global')
        self.scope_actual = self.scope_global
        
        # Pre-cargar funciones built-in
        self.scope_global.definir(Simbolo('imprimir', 'void', 'funcion'))
        self.scope_global.definir(Simbolo('leer', 'string', 'funcion'))
        self.scope_global.definir(Simbolo('calcular_total', 'decimal', 'funcion'))
    
    def entrar_scope(self, nombre: str):
        nuevo = Scope(nombre, padre=self.scope_actual)
        self.scope_actual = nuevo
    
    def salir_scope(self):
        self.scope_actual = self.scope_actual.padre
    
    def definir(self, nombre: str, tipo: str, categoria: str = 'variable'):
        self.scope_actual.definir(Simbolo(nombre, tipo, categoria))
    
    def resolver(self, nombre: str) -> Simbolo:
        sim = self.scope_actual.resolver(nombre)
        if sim is None:
            raise SemanticError(f"'{nombre}' no ha sido declarado")
        return sim

class SemanticError(Exception):
    pass

Ejemplo de scoping:

programa ejemplo
variables:
    x: entero    ← scope global del programa

inicio
    x <- 10
    si x > 5 entonces
        # Aquí 'x' del scope global es visible
        imprimir(x)    # OK
    fin_si
fin

4.2 El patrón Visitor

💡 Intuición

Necesitamos recorrer el AST para hacer múltiples operaciones: verificar tipos, generar código, calcular el tamaño del stack... Podríamos agregar un método por operación a cada nodo, pero eso viola el Principio de Responsabilidad Única.

El patrón Visitor separa los algoritmos de la estructura de datos: el AST sabe cómo "aceptar" un visitor; el visitor sabe qué hacer con cada tipo de nodo.

📐 Fundamento

from abc import ABC, abstractmethod

class Visitor(ABC):
    """Interface que todos los visitors deben implementar."""
    
    @abstractmethod
    def visitar_programa(self, nodo: Programa): ...
    
    @abstractmethod
    def visitar_asignacion(self, nodo: Asignacion): ...
    
    @abstractmethod
    def visitar_si_stmt(self, nodo: SiStmt): ...
    
    @abstractmethod
    def visitar_imprimir(self, nodo: ImprimirStmt): ...
    
    @abstractmethod
    def visitar_binop(self, nodo: BinOp): ...
    
    @abstractmethod
    def visitar_numero(self, nodo: Numero): ...
    
    @abstractmethod
    def visitar_identificador(self, nodo: Identificador): ...

# Agregar método accept() a cada nodo del AST:
# (modificando las dataclasses del capítulo anterior)
class Programa:
    def accept(self, visitor): return visitor.visitar_programa(self)

class Asignacion:
    def accept(self, visitor): return visitor.visitar_asignacion(self)

# etc.

Verificador de tipos — un Visitor concreto:

class VerificadorTipos(Visitor):
    TIPOS_NUMERICOS = {'entero', 'decimal'}
    
    def __init__(self):
        self.tabla = TablaSímbolos()
    
    def visitar_programa(self, nodo: Programa):
        # Registrar todas las declaraciones primero
        for decl in nodo.declaraciones:
            self.tabla.definir(decl.nombre, decl.tipo)
        
        # Verificar el cuerpo
        for stmt in nodo.cuerpo:
            stmt.accept(self)
    
    def visitar_asignacion(self, nodo: Asignacion):
        tipo_destino = self.tabla.resolver(nodo.destino).tipo
        tipo_valor = nodo.valor.accept(self)
        
        if not self._tipos_compatibles(tipo_destino, tipo_valor):
            raise SemanticError(
                f"Tipo incompatible: no se puede asignar '{tipo_valor}' "
                f"a '{nodo.destino}' (tipo: '{tipo_destino}')"
            )
    
    def visitar_binop(self, nodo: BinOp) -> str:
        tipo_izq = nodo.izq.accept(self)
        tipo_der = nodo.der.accept(self)
        
        if nodo.op in ('+', '-', '*', '/'):
            if tipo_izq == 'string' and nodo.op == '+':
                return 'string'  # concatenación
            if tipo_izq in self.TIPOS_NUMERICOS and tipo_der in self.TIPOS_NUMERICOS:
                # Si alguno es decimal, el resultado es decimal
                return 'decimal' if 'decimal' in (tipo_izq, tipo_der) else 'entero'
            raise SemanticError(
                f"Operador '{nodo.op}' no aplicable a tipos '{tipo_izq}' y '{tipo_der}'"
            )
        
        if nodo.op in ('==', '!=', '<', '>', '<=', '>='):
            if tipo_izq != tipo_der:
                raise SemanticError(
                    f"No se puede comparar '{tipo_izq}' con '{tipo_der}'"
                )
            return 'booleano'
    
    def visitar_numero(self, nodo: Numero) -> str:
        return 'entero' if isinstance(nodo.valor, int) else 'decimal'
    
    def visitar_identificador(self, nodo: Identificador) -> str:
        return self.tabla.resolver(nodo.nombre).tipo
    
    def visitar_si_stmt(self, nodo: SiStmt):
        tipo_cond = nodo.condicion.accept(self)
        if tipo_cond != 'booleano':
            raise SemanticError(f"La condición del 'si' debe ser booleana, no '{tipo_cond}'")
        for stmt in nodo.entonces:
            stmt.accept(self)
        if nodo.sino:
            for stmt in nodo.sino:
                stmt.accept(self)
    
    def visitar_imprimir(self, nodo: ImprimirStmt):
        nodo.expresion.accept(self)  # solo verificar que la expr sea válida
    
    def _tipos_compatibles(self, destino: str, fuente: str) -> bool:
        if destino == fuente:
            return True
        if destino == 'decimal' and fuente == 'entero':
            return True  # widening automático
        return False

Probar el verificador:

# Código PupusaScript con error de tipos
codigo_error = '''
programa prueba
variables:
    precio: decimal
    nombre: string
inicio
    nombre <- precio + 5    # ERROR: no se puede asignar decimal a string
fin
'''

tokens = tokenizar(codigo_error)
ast = parser.parse(tokens)
verificador = VerificadorTipos()
try:
    ast.accept(verificador)
    print("Programa válido")
except SemanticError as e:
    print(f"Error semántico: {e}")
# → Error semántico: Tipo incompatible: no se puede asignar 'decimal' a 'nombre' (tipo: 'string')

4.3 Verificación estática vs dinámica

📐 Fundamento

Verificación estática: en tiempo de compilación, antes de ejecutar. Lenguajes: Java, C, Haskell, Rust.

// Java — error en tiempo de compilación
String s = 5;  // ERROR: incompatible types: int cannot be converted to String

Verificación dinámica: en tiempo de ejecución. Lenguajes: Python, JavaScript, Ruby.

# Python — error en tiempo de ejecución
s = "hola" + 5  # TypeError: can only concatenate str (not "int") to str

Tradeoffs:

Estática Dinámica
¿Cuándo se detectan errores? Antes de ejecutar Al ejecutar
¿Velocidad de desarrollo? Más lento (tipos explícitos) Más rápido
¿Seguridad de tipos? Alta Baja
¿Rendimiento en tiempo de ejecución? Mayor Menor (checks en runtime)
Ejemplos Java, C++, Rust, Go Python, JS, Ruby, PHP

PupusaScript usa verificación estática — los tipos se declaran explícitamente y se verifican antes de ejecutar.

Inferencia de tipos: El compilador deduce el tipo sin que el programador lo declare. Haskell, ML, Rust, y TypeScript tienen inferencia de tipos.

// Rust — el compilador infiere que x es i32
let x = 42;  // no necesitamos escribir: let x: i32 = 42;

4.4 Ejercicios

✏️ Ejercicio 4.1 — Identificar errores semánticos

Para cada fragmento de PupusaScript, indicá si hay error semántico, cuál es, y cómo lo detectaría el verificador:

a. resultado <- x + y donde x es entero y y no fue declarada.

b. si "activo" entonces ... fin_si (la condición es un string, no booleano).

c. precio <- precio + descuento donde precio es decimal y descuento es entero.

✏️ Ejercicio 4.2 — Extender el verificador

Extendé el VerificadorTipos para soportar el bucle mientras:

mientras x > 0 hacer
    x <- x - 1
fin_mientras

Escribí el método visitar_mientras_stmt indicando qué verifica.


4.5 Para profundizar


Definiciones nuevas: análisis semántico, tabla de símbolos, scope, símbolo, scoping léxico, patrón Visitor, verificación de tipos, verificación estática, verificación dinámica, inferencia de tipos, widening (conversión implícita), AST anotado.