El Call Stack en JavaScript: cómo se ejecutan las llamadas a función

En esta página
Enterate del próximo post
Un mail cuando publico algo nuevo. Sin spam y te podés dar de baja cuando quieras.

Un mail cuando publico algo nuevo. Sin spam y te podés dar de baja cuando quieras.
Un programa no ejecuta todo su código de una sola vez, en paralelo: entra a una función, se detiene ahí hasta que esa función termina, y solo entonces retoma lo que había quedado pendiente. Ese orden (qué se ejecuta primero, qué queda en pausa, en qué momento se retoma) lo administra una estructura simple: el call stack.
function saludar() {
console.log("Hola");
}
console.log("Antes");
// Antes
saludar();
// Hola
console.log("Después");
// DespuésLa salida no sigue el orden en que aparecen las líneas en el archivo: sigue el orden en que se ejecutan. Cuando el motor llega a saludar(), no sigue de largo con la línea siguiente. Se detiene, entra a ejecutar el cuerpo completo de saludar, y solo cuando termina de ejecutarlo retoma el código que había quedado esperando. Ese código en pausa (console.log("Después"), todavía sin ejecutarse) tiene que quedar guardado en algún lado mientras saludar corre. Ese lugar es el call stack.
El call stack es una pila: cada vez que se llama a una función, se agrega un elemento arriba de todo (se dice que se "apila" o que se hace push); cuando esa función termina, ese elemento se saca (se "desapila" o se hace pop). Solo el elemento de arriba de todo está en ejecución en cada instante; todos los demás quedan en pausa, esperando su turno para retomar.
function tercero() {
console.log("tercero");
}
function segundo() {
console.log("segundo, antes de llamar a tercero");
tercero();
console.log("segundo, después de llamar a tercero");
}
function primero() {
console.log("primero, antes de llamar a segundo");
segundo();
console.log("primero, después de llamar a segundo");
}
primero();
// primero, antes de llamar a segundo
// segundo, antes de llamar a tercero
// tercero
// segundo, después de llamar a tercero
// primero, después de llamar a segundoprimero() se apila y arranca a ejecutarse. Antes de terminar, llama a segundo(), que se apila arriba de primero y pasa a ser la que se ejecuta. segundo, a su vez, llama a tercero(), que se apila arriba de todo. En ese punto la pila tiene tres llamadas: primero y segundo en pausa, tercero ejecutándose. Cuando tercero termina, se desapila, y el control vuelve exactamente al punto de segundo donde había quedado (la línea siguiente a la llamada). Lo mismo pasa cuando segundo termina: se desapila, y el control vuelve a primero. Cada elemento de la pila se llama frame o marco de ejecución, y guarda lo necesario para retomar esa llamada puntual: en qué línea seguir, y los valores de sus propias variables locales.
El ejemplo anterior ya mostró el mecanismo completo, solo que sin nombrarlo con esas palabras. Cada console.log de primero, segundo y tercero marca un momento distinto de la pila. El primero en imprimirse (primero, antes de llamar a segundo) corresponde al instante en que solo primero está apilada. El segundo (segundo, antes de llamar a tercero) corresponde a segundo apilándose arriba de primero. El tercero (tercero) corresponde al momento con las tres llamadas apiladas a la vez: primero, segundo y tercero, una arriba de la otra.
De ahí en adelante el orden se invierte, porque la salida es siempre por el mismo extremo por el que se entró: tercero termina y se desapila primero, por ser la última en haberse apilado; después segundo; después primero. Esa regla (la última en entrar es la primera en salir) es la que ordena toda la ejecución, y es la misma razón por la que el segundo console.log de segundo ("después de llamar a tercero") solo puede imprimirse una vez que tercero ya se desapiló por completo.

return para la pilaUna función termina, y por lo tanto se desapila, en dos casos: cuando llega a la llave de cierre sin haber ejecutado ningún return, o cuando ejecuta un return explícito. En los dos casos el efecto sobre la pila es el mismo (el frame se saca), pero return además deja un valor disponible para quien hizo la llamada.
function calcular() {
console.log("calculando...");
return 4 + 4;
}
function usar() {
console.log("antes de llamar");
const resultado = calcular();
console.log("resultado:", resultado);
}
usar();
// antes de llamar
// calculando...
// resultado: 8calcular() se apila, ejecuta su console.log, llega a return 4 + 4 y se desapila de inmediato: ninguna línea después de un return alcanza a ejecutarse dentro de esa misma llamada. El valor 8 queda disponible en el lugar exacto donde se hizo la llamada, así que const resultado = calcular() guarda ese valor y usar() sigue ejecutándose con resultado ya definido.
La pila tiene un límite de tamaño. Una función que se llama a sí misma sin ningún caso en el que se detenga agrega un frame nuevo en cada llamada, sin desapilar ninguno, hasta llegar a ese límite.
function contarInfinito(n) {
return contarInfinito(n + 1);
}
contarInfinito(1);
// RangeError: Maximum call stack size exceededcontarInfinito nunca desapila nada: cada llamada hace return de otra llamada a sí misma, así que el frame anterior queda esperando un valor que nunca termina de calcularse, y la pila sigue creciendo. Cuando se queda sin espacio, el motor deja de apilar y termina la ejecución con ese error. Es el mismo tipo de interrupción que ya se vio en el post anterior con PI = 3 sobre una const: el código deja de ejecutarse en el momento exacto del error, sin llegar a la siguiente línea. Un RangeError de este tipo es lo que aparece en la consola cuando una función recursiva (una función que se llama a sí misma) no tiene un caso base que la detenga, algo frecuente al escribir las primeras funciones recursivas.
El call stack es una pila de llamadas pendientes: crece con cada función nueva que se llama y se achica con cada una que termina, siempre por el mismo extremo. Mientras una función se ejecuta, todo lo que la llamó queda pausado debajo de ella en la pila, esperando su turno para retomar donde había quedado. Cuando la pila no tiene más espacio para crecer, el resultado es un
RangeErrorde stack overflow, casi siempre por una función recursiva sin caso base.
El próximo post de la serie profundiza en la diferencia entre expresión y sentencia: qué distingue a una de otra dentro del código que ya se viene escribiendo en esta serie, y por qué esa distinción explica algunos comportamientos que a primera vista pueden parecer inconsistentes.