Lenguajes formales y autómatas

"Todo lenguaje de programación es un lenguaje formal. Los compiladores son la implementación de la teoría de autómatas."

Qué vas a aprender en este capítulo

Antes de construir un compilador, necesitamos entender qué es un lenguaje formalmente. ¿Qué hace que if (x > 0) { sea Python inválido pero JavaScript válido? ¿Por qué es imposible parsear HTML con expresiones regulares (aunque muchos lo intenten)? La teoría de lenguajes formales responde estas preguntas.


1.1 Conceptos básicos

💡 Intuición

Un alfabeto es un conjunto de símbolos: {a, b, c}, {0, 1}, {'if', 'while', '+'}.

Un lenguaje es un conjunto (posiblemente infinito) de cadenas sobre ese alfabeto. El lenguaje Python es el conjunto de todos los programas Python válidos — infinito, pero con reglas precisas.

Una gramática describe las reglas para construir cadenas válidas. Un autómata reconoce si una cadena pertenece al lenguaje.

📐 Fundamento

Definiciones formales:

  • Alfabeto Σ\Sigma: conjunto finito de símbolos. Ej: Σ={0,1}\Sigma = {0, 1}
  • Cadena (string): secuencia finita de símbolos de Σ\Sigma. Ej: "0101"
  • Σ\Sigma^*: conjunto de todas las cadenas posibles sobre Σ\Sigma (incluyendo ε\varepsilon, la cadena vacía)
  • Lenguaje LΣL \subseteq \Sigma^: cualquier subconjunto de Σ\Sigma^

La jerarquía de Chomsky:

Tipo Lenguaje Gramática Autómata Ejemplo
Tipo 0 Recursivamente enumerable Sin restricción Máquina de Turing Problema de la parada
Tipo 1 Sensible al contexto Contexto sensible Autómata linealmente acotado C++ (algunas construcciones)
Tipo 2 Libre de contexto CFG Autómata de pila La mayoría de lenguajes de programación
Tipo 3 Regular Gramática regular Autómata finito Expresiones regulares, tokens

Para los compiladores: Los tokens (palabras del lenguaje: identificadores, números, palabras clave) son lenguajes regulares (Tipo 3). La sintaxis del lenguaje es libre de contexto (Tipo 2).


1.2 Autómatas finitos

💡 Intuición

Un autómata finito es una máquina de estados muy simple: está en algún estado, lee un símbolo, transiciona a otro estado. Si al terminar de leer la cadena está en un estado de "aceptación", la cadena pertenece al lenguaje.

Pensalo como un semáforo: tiene estados (verde/amarillo/rojo), transiciones (timer), y un estado inicial. Lo interesante es que esta máquina tan simple puede reconocer patrones complejos.

📐 Fundamento

DFA (Deterministic Finite Automaton):

Formalmente: M=(Q,Σ,δ,q0,F)M = (Q, \Sigma, \delta, q_0, F) donde:

  • QQ: conjunto de estados
  • Σ\Sigma: alfabeto
  • δ:Q×ΣQ\delta: Q \times \Sigma \to Q: función de transición (determinista)
  • q0Qq_0 \in Q: estado inicial
  • FQF \subseteq Q: estados de aceptación

Ejemplo — Autómata que acepta strings con número par de ceros:

        0            0
→ (q0) ──→ (q1) ──→ (q0)  ← estado de aceptación
   ↑    1    ↓    1    ↑
   └─────────────────────┘

q0 = "visto número par de ceros" (aceptación)
q1 = "visto número impar de ceros"

Implementación en Python:

def dfa_par_ceros(cadena: str) -> bool:
    """Acepta si la cadena tiene un número par de ceros."""
    # Estado: True = par (q0), False = impar (q1)
    estado = True  # q0, par (0 es par)
    
    for simbolo in cadena:
        if simbolo == '0':
            estado = not estado  # cambiar paridad
        # Si es '1', el estado no cambia
    
    return estado  # True si terminamos en q0 (par)

print(dfa_par_ceros(""))        # True (0 ceros, par)
print(dfa_par_ceros("00"))      # True (2 ceros)
print(dfa_par_ceros("0"))       # False (1 cero)
print(dfa_par_ceros("10100"))   # False (3 ceros)
print(dfa_par_ceros("101001"))  # True (4 ceros)

NFA (Nondeterministic Finite Automaton):

El NFA puede tener múltiples transiciones para el mismo estado+símbolo, o transiciones ε\varepsilon (sin consumir símbolo). Acepta si algún camino de ejecución llega a un estado de aceptación.

Teorema: Todo NFA tiene un DFA equivalente (construcción de subconjuntos). Los NFAs son más fáciles de diseñar, los DFAs son más eficientes de ejecutar.

# NFA para tokens que empiezan con 'if' o 'in':
#
# → (q0) ─'i'→ (q1) ─'f'→ ((q2))  ← acepta "if"
#                   └─'n'→ ((q3))  ← acepta "in"
#
# La conversión a DFA crea estados compuestos: {q0}, {q1}, {q2,q3}

1.3 Expresiones regulares

💡 Intuición

Las expresiones regulares son una notación compacta para describir lenguajes regulares. Son exactamente lo que se necesita para describir los tokens de un lenguaje: identificadores, números, literales de string.

Hay un resultado fundamental: toda expresión regular corresponde a un NFA, y todo DFA puede expresarse como una expresión regular. Son tres formas de describir lo mismo.

📐 Fundamento

Operaciones base:

Notación Significado Ejemplo
aa El símbolo literal a a
ε\varepsilon Cadena vacía ``
R1R2R_1 | R_2 Alternativa (o) a|b = {a, b}
R1R2R_1 R_2 Concatenación ab = {ab}
RR^* Kleene star (0 o más) a* = {ε, a, aa, aaa, ...}
R+R^+ Una o más a+ = {a, aa, aaa, ...}
R?R? Cero o uno a? = {ε, a}

Tokens de PupusaScript definidos con regex:

import re

# Definición de tokens del lenguaje PupusaScript
TOKENS = [
    ('NUMERO_DECIMAL', r'\d+\.\d+'),          # 3.14, 1.13
    ('NUMERO_ENTERO',  r'\d+'),               # 42, 0
    ('STRING',         r'"[^"]*"'),           # "hola"
    ('PALABRACLAVE',   r'\b(si|sino|fin_si|mientras|fin_mientras|'
                       r'inicio|fin|variables|entero|decimal|'
                       r'imprimir|leer|programa)\b'),
    ('IDENTIFICADOR',  r'[a-záéíóúA-ZÁÉÍÓÚ_][a-záéíóúA-ZÁÉÍÓÚ_0-9]*'),
    ('ASIGNACION',     r'<-'),
    ('OPERADOR',       r'[+\-*/]|>=|<=|!=|==|>|<'),
    ('LPAREN',         r'\('),
    ('RPAREN',         r'\)'),
    ('COMA',           r','),
    ('DOSPUNTOS',      r':'),
    ('NEWLINE',        r'\n'),
    ('ESPACIO',        r'[ \t]+'),             # ignorar
    ('COMENTARIO',     r'#[^\n]*'),            # ignorar
]

def tokenizar(texto: str) -> list:
    tokens = []
    pos = 0
    while pos < len(texto):
        for tipo, patron in TOKENS:
            m = re.match(patron, texto[pos:])
            if m:
                valor = m.group(0)
                if tipo not in ('ESPACIO', 'COMENTARIO', 'NEWLINE'):
                    tokens.append((tipo, valor))
                pos += len(valor)
                break
        else:
            raise SyntaxError(f"Carácter inesperado: {texto[pos]!r} en posición {pos}")
    return tokens

codigo = 'total <- 8.50 * 1.13'
print(tokenizar(codigo))
# [('IDENTIFICADOR', 'total'), ('ASIGNACION', '<-'), 
#  ('NUMERO_DECIMAL', '8.50'), ('OPERADOR', '*'), ('NUMERO_DECIMAL', '1.13')]

¿Por qué las ER no bastan para parsear?

Las expresiones regulares no pueden contar balances de paréntesis:

¿Las siguientes cadenas están balanceadas?
"(())"        → sí
"(()"         → no
"(((())))"    → sí

No existe ninguna expresión regular que reconozca exactamente 
todas las cadenas con paréntesis balanceados.
(Demostración: Lema de bombeo para lenguajes regulares)

Para paréntesis balanceados (y cualquier construcción anidada), se necesita un autómata de pila — que corresponde a las gramáticas libres de contexto del Capítulo 3.


1.4 Gramáticas libres de contexto (CFG)

📐 Fundamento

Una gramática libre de contexto G=(V,Σ,P,S)G = (V, \Sigma, P, S) donde:

  • VV: variables (no terminales): expr, stmt, if_stmt
  • Σ\Sigma: terminales (tokens): if, (, ), numero
  • PP: producciones (reglas): expr → expr '+' term | term
  • SVS \in V: símbolo inicial

Gramática de expresiones aritméticas:

expr   → expr '+' term     # suma
       | expr '-' term     # resta
       | term

term   → term '*' factor   # multiplicación
       | term '/' factor   # división
       | factor

factor → '(' expr ')'      # paréntesis
       | NUMERO
       | IDENTIFICADOR

Esta gramática captura la precedencia de operadores: la multiplicación está más "adentro" de la jerarquía, por lo que tiene mayor precedencia que la suma.

Parse tree de 2 + 3 * 4:

        expr
       /    \
     expr   '+' term
      |          \
    term        term '*' factor
      |          |           |
    factor      factor       4
      |           |
      2           3

Resultado: 2 + (3*4) = 14. La gramática captura correctamente que * tiene mayor precedencia.

Tipos de parsers (anticipación al Capítulo 3):

  • LL parsers (top-down): leen de Izquierda a derecha, derivación Izquierda.
  • LR parsers (bottom-up): leen de Izquierda a derecha, derivación más a la derecha.

1.5 Ejercicios

✏️ Ejercicio 1.1 — Diseñar DFA

Diseñá un DFA que acepte todas las cadenas binarias (sobre {0,1}) que terminan en '01'. Dibujá el diagrama de estados e indicá el estado inicial y los estados de aceptación.

✏️ Ejercicio 1.2 — Regex para tokens

Escribí expresiones regulares para los siguientes tokens:

a. Número de DUI salvadoreño: 9 dígitos, guión, 1 dígito (ej: "01234567-8") b. Dirección de email básica: letras/números/puntos, @, dominio, punto, extensión c. Comentario de una línea en Python: empieza con #, sigue hasta fin de línea d. Literal de string en Python con comillas dobles (sin escapados): "texto cualquiera"


1.6 Para profundizar


Definiciones nuevas: alfabeto, lenguaje formal, gramática, autómata, jerarquía de Chomsky, lenguaje regular, lenguaje libre de contexto, DFA, NFA, expresión regular, Kleene star, gramática libre de contexto (CFG), terminal, no terminal, producción, árbol de derivación (parse tree).