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 : conjunto finito de símbolos. Ej:
- Cadena (string): secuencia finita de símbolos de . Ej: "0101"
- : conjunto de todas las cadenas posibles sobre (incluyendo , la cadena vacía)
- Lenguaje : cualquier subconjunto de
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: donde:
- : conjunto de estados
- : alfabeto
- : función de transición (determinista)
- : estado inicial
- : 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 (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 |
|---|---|---|
| El símbolo literal a | a |
|
| Cadena vacía | `` | |
| Alternativa (o) | a|b = {a, b} |
|
| Concatenación | ab = {ab} |
|
| Kleene star (0 o más) | a* = {ε, a, aa, aaa, ...} |
|
| Una o más | a+ = {a, aa, aaa, ...} |
|
| 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 donde:
- : variables (no terminales):
expr,stmt,if_stmt - : terminales (tokens):
if,(,),numero - : producciones (reglas):
expr → expr '+' term | term - : 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.
Solución
Estados:
- q0: "ningún prefijo relevante" (estado inicial)
- q1: "último símbolo visto fue '0'"
- q2: "últimos dos símbolos vistos fueron '01'" (estado de aceptación)
Transiciones:
δ(q0, 0) = q1 δ(q0, 1) = q0
δ(q1, 0) = q1 δ(q1, 1) = q2
δ(q2, 0) = q1 δ(q2, 1) = q0
Diagrama:
0 1 0
→ (q0) ──→ (q1) ──→ ((q2)) ──→ (q1)
↑ 1 │ 0 ↑ 1 ↓
└─────────┘ └─────────────┘
Verificación: "101" → q0→q0→q1→q2 ✓ | "100" → q0→q0→q1→q1 ✗
✏️ 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"
Solución
a. DUI: \d{9}-\d
b. Email (simplificado): [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}
c. Comentario Python: #[^\n]*
d. String Python (sin escapados): "[^"]*" (empieza con ", cualquier carácter excepto ", termina con ")
Nota sobre d: El caso real de Python con escapados requiere: "([^"\\]|\\.)*" — o " que acepta cualquier carácter excepto " sin barra invertida, o una barra invertida seguida de cualquier carácter.
1.6 Para profundizar
- Hopcroft, Motwani & Ullman, Introduction to Automata Theory, cap. 1-3.
- Sipser, Introduction to the Theory of Computation.
- Siguiente: Análisis léxico.
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).