wandres.dev
FUNCIONES · valores de primera clase

Llamadas de cola: la garantía que convierte la recursión en un bucle

Lua no optimiza las llamadas de cola: las define. La diferencia entre esas dos formulaciones es todo el contenido de esta lección, porque una optimización es una promesa del compilador que puede no cumplirse y una definición semántica es una garantía del lenguaje sobre la que se puede construir. Aquí se examina qué dice exactamente el manual de referencia, por qué una llamada propiamente de cola no consume pila y qué significa que el marco del llamante desaparezca antes de que el llamado empiece. Después se estudia con precisión quirúrgica la forma sintáctica exacta que califica, y sobre todo las variantes que la gente cree equivalentes y no lo son, desde el paréntesis que trunca hasta la operación aritmética que deja trabajo pendiente o la variable de cierre que obliga a conservar el marco. La tercera parte muestra la consecuencia práctica más valiosa: cada estado de un autómata puede ser una función que salta al siguiente con un retorno, produciendo máquinas de estados que corren indefinidamente en espacio constante y sin ninguna estructura de control explícita. Se discute también el precio, la pérdida del rastro de llamadas en las trazas de error.

⏱ 18 min

Casi todos los lenguajes que dicen soportar recursión de cola en realidad no lo dicen: lo insinúan. Su compilador la aplica cuando puede, la abandona cuando el código se complica, la desactiva en compilaciones de depuración y no ofrece ninguna forma de comprobar si en un punto concreto la aplicó o no. El resultado es que ningún programador razonable puede escribir un programa cuya corrección dependa de ella, y por tanto la característica, aunque exista, no cambia cómo se escribe. Lua hizo lo contrario, y lo hizo en el sitio correcto: no en el compilador sino en la definición del lenguaje. El manual de referencia establece que ciertas llamadas, escritas de una forma sintáctica precisa, no hacen crecer la pila, y esa afirmación es tan vinculante como la de que las tablas se indexan con corchetes. No es una promesa de rendimiento, es una garantía semántica, y sobre las garantías semánticas sí se puede construir. Esta lección trata de qué se puede construir exactamente.

🎯 Al terminar esta lección sabrás
  • Enunciar con precisión qué garantiza el manual sobre las llamadas de cola y en qué se diferencia de una optimización.
  • Escribir la forma sintáctica exacta que califica, y reconocer al vuelo las variantes que la anulan.
  • Convertir una recursión que acumula en la vuelta en otra que acumula en el argumento.
  • Construir máquinas de estados como funciones que saltan entre sí en espacio constante.

Lo que Lua garantiza, y por qué es una garantía

La regla es la siguiente: cuando una función termina con un retorno cuyo contenido es exactamente una llamada, Lua descarta el marco de pila del llamante antes de entrar en el llamado. No hay nada que hacer al volver, porque no se vuelve: el resultado del llamado será directamente el resultado del llamante, así que conservar el marco sería guardar un contexto que nadie va a consultar. La consecuencia es que una cadena de llamadas de cola de cualquier longitud ocupa un solo marco.

-- Diez millones de niveles de profundidad, espacio constante
local function contar(n)
  if n == 0 then return "fin" end
  return contar(n - 1)
end
print(contar(10000000))        --> fin

-- La version que NO es de cola desborda enseguida
local function malo(n)
  if n == 0 then return 0 end
  return 1 + malo(n - 1)       -- queda una suma pendiente al volver
end
-- malo(10000000)  --> stack overflow

La comparación entre las dos últimas funciones aísla el criterio con nitidez. En la segunda hay trabajo pendiente después de la llamada, la suma, y ese trabajo necesita que alguien recuerde dónde continuar; el marco es precisamente ese recuerdo, y por eso no se puede tirar. En la primera no queda nada pendiente y el marco es basura desde el instante en que se prepara la llamada.

La forma exacta, y las variantes que la anulan

Aquí conviene ser literal, porque la diferencia entre calificar y no calificar es sintáctica y las variantes fallidas se parecen mucho a la buena. Solo es llamada de cola un retorno cuya lista de valores consista únicamente en una llamada a función, sin paréntesis envolventes, sin operadores, sin valores adicionales y sin conversiones.

-- SI son llamadas de cola
return f(x)
return t.metodo(a, b)
return obj:metodo(a)          -- el azucar de dos puntos no estorba
return f(...)                 -- reenvio de varargs, tambien califica

-- NO son llamadas de cola
return (f(x))                 -- los parentesis ajustan a un valor: hay trabajo
return f(x) + 1               -- queda una suma pendiente
return f(x), 0                -- la llamada no es el unico valor de la lista
return f(x) or 0              -- hay que inspeccionar el resultado
local r = f(x); return r      -- se vuelve al llamante antes de retornar

-- Caso sutil: una variable de cierre en ambito impide ceder el marco
local function con_cierre()
  local recurso <close> = abrir()
  return siguiente()          -- hay que cerrar el recurso al salir
end

El último caso es el más fácil de pasar por alto. Una variable marcada para cerrarse obliga a ejecutar su cierre cuando el ámbito termina, y ese cierre es trabajo pendiente después de la llamada; el marco debe sobrevivir para hacerlo. Lo mismo ocurre, por el mismo motivo, con cualquier construcción que necesite ejecutar algo a la salida.

⚠️
El precio se paga en las trazas de error

Ceder el marco significa borrar la evidencia de que el llamante existió. Cuando una cadena de llamadas de cola termina en error, la traza no muestra los pasos intermedios sino una marca que indica que hubo llamadas de cola omitidas, y con ella se pierde el recorrido que te habría dicho cómo llegaste hasta ahí. En un autómata con cincuenta estados eso convierte la depuración en trabajo detectivesco, y la contramedida habitual es registrar la transición antes de saltar, no confiar en la traza. Es un intercambio real y consciente: espacio constante a cambio de historia.

Escribirla bien: mover el acumulador al argumento

Convertir una recursión corriente en una de cola no es un truco de sintaxis sino un cambio en dónde vive el resultado parcial. Si el valor se construye al volver, hay trabajo pendiente por definición; si se construye al bajar y viaja como argumento, no queda nada que hacer en el retorno.

-- Acumula en la VUELTA: no es de cola
local function suma(n)
  if n == 0 then return 0 end
  return n + suma(n - 1)
end

-- Acumula en el ARGUMENTO: si es de cola
local function suma(n, acc)
  acc = acc or 0
  if n == 0 then return acc end
  return suma(n - 1, acc + n)
end

-- Recursion mutua: tambien en espacio constante
local par, impar
function par(n)   if n == 0 then return true  end return impar(n - 1) end
function impar(n) if n == 0 then return false end return par(n - 1)   end
print(par(5000000))            --> false, sin desbordar nada

La recursión mutua es donde la garantía luce más, porque es justamente el caso que ningún bucle expresa cómodamente. Dos funciones que se llaman en cola alternándose son un autómata de dos estados, y esa lectura es la que abre la sección siguiente.

Máquinas de estados sin estructura de control

Si un salto de cola no cuesta pila, entonces una función puede ser un estado y un retorno puede ser una transición. El autómata resultante no necesita un bucle envolvente, ni una tabla de despacho, ni una variable que guarde el estado actual: el estado actual es la función que se está ejecutando, y la transición es literalmente ir a la siguiente.

-- Reconocedor de numeros con signo, un estado por funcion
local inicio, signo, digitos

function inicio(s, i)
  local c = s:sub(i, i)
  if c == "+" or c == "-" then return signo(s, i + 1) end
  return signo(s, i)
end

function signo(s, i)
  if s:sub(i, i):match("%d") then return digitos(s, i + 1) end
  return false
end

function digitos(s, i)
  if i > #s then return true end
  if s:sub(i, i):match("%d") then return digitos(s, i + 1) end
  return false
end

print(inicio("-4096", 1))      --> true
print(inicio("12a", 1))        --> false

Cada transición es un salto que no deja rastro y no consume espacio, de modo que el reconocedor procesa una entrada de cualquier tamaño con un marco de pila. Ese es el patrón que sostiene analizadores léxicos, protocolos de red, intérpretes de bytecode y bucles de servidores escritos en Lua.

🧮

Garantía, no optimización

Está en la definición del lenguaje, no en el humor del compilador. Por eso se puede escribir código que dependa de ella.

✂️

La forma es literal

Solo un retorno cuyo único contenido es una llamada. Un paréntesis, un operador o un valor extra la anulan.

⬇️

Acumula al bajar

Si el resultado parcial viaja como argumento no queda trabajo en la vuelta, y la recursión se vuelve de cola.

🔀

Estado igual a función

Un autómata es un conjunto de funciones que saltan entre sí. Sin bucle, sin variable de estado, en espacio constante.

flowchart LR
A[Marco del llamante] --> B{Queda trabajo tras la llamada}
B -->|Si| C[Se conserva el marco y la pila crece]
B -->|No| D[Se descarta el marco antes de entrar]
D --> E[Cadena de saltos en espacio constante y sin bucle envolvente]
style D fill:#a6e3a1,color:#11111b
style C fill:#f38ba8,color:#11111b
Ponerlo en la semántica y no en el compilador es lo que lo vuelve utilizable

La distinción entre optimizar las llamadas de cola y definirlas parece un matiz de redacción del manual y decide por completo si la característica sirve para algo. Una optimización es una propiedad de una implementación concreta, y por tanto un programa que dependa de ella es un programa que funciona en la máquina donde se probó. Nadie escribe autómatas de cientos de miles de transiciones sobre una promesa así, del mismo modo que nadie escribe código que dependa de que el compilador desenrolle un bucle. Al llevar la propiedad a la definición del lenguaje, Lua la convierte en algo distinto en naturaleza: pasa a ser un contrato que cualquier implementación conforme debe cumplir, y por tanto pasa a ser una construcción de programación en lugar de un detalle de rendimiento. Eso es lo que habilita un estilo entero. La recursión deja de ser la técnica elegante que se enseña y se abandona por miedo al desbordamiento, y se convierte en la forma natural de expresar cualquier proceso iterativo cuyo siguiente paso dependa de una decisión, que es exactamente el caso que los bucles expresan mal. Un bucle obliga a codificar el estado en variables y las transiciones en condicionales anidados; un conjunto de funciones que saltan entre sí codifica el estado en el nombre de la función que está corriendo y la transición en una línea legible que dice a dónde va. La segunda forma se lee como la especificación del autómata, y esa correspondencia entre el diagrama y el código es la que evita clases enteras de errores. Hay más consecuencias, y llegan lejos: el salto de cola es también lo que permite implementar intérpretes con despacho por función sin acumular pila, escribir estilo de paso de continuaciones sin trampolín, encadenar planificadores de corrutinas y construir protocolos donde cada mensaje recibido salta al manejador del siguiente estado indefinidamente. Todo eso se apoya en la misma frase del manual. Y el precio, que existe, está bien elegido: se pierde el rastro de llamadas en las trazas de error, porque conservar el rastro sería exactamente conservar los marcos que la garantía promete descartar. Lua no puede darte las dos cosas, y prefirió la que permite escribir programas que antes no se podían escribir sobre la que hace más cómodo depurar los que ya se podían. Como en el resto del lenguaje, la decisión no fue añadir una comodidad sino elegir qué imposibilidad eliminar.

⚔️ Comprueba la garantía y después apóyate en ella
  1. Escribe la suma recursiva en sus dos formas, la que acumula en la vuelta y la que acumula en el argumento, y ejecútalas con un millón de niveles. Anota cuál falla y en qué punto.
  2. Toma una llamada de cola correcta y estropéala de cuatro maneras distintas: envolviéndola en paréntesis, sumándole algo, añadiéndole un segundo valor y guardándola antes en una variable. Verifica que las cuatro desbordan.
  3. Convierte una función recursiva sobre una lista en versión de cola pasando el índice y el acumulador, y comprueba que el resultado es idéntico.
  4. Provoca un error dentro de una cadena larga de llamadas de cola, imprime la traza y localiza la marca que indica los pasos omitidos. Explica qué información perdiste.
  5. Escribe un reconocedor de tres o cuatro estados como funciones que saltan entre sí, dibuja antes el diagrama, comprueba que el código se lee igual que el dibujo y después declara en él una variable de cierre para demostrar que la transición deja de ser de cola.