wandres.dev
EL ÁRBOL DE DUEÑOS · Ownership y disposal automático

Limpieza en cascada

Desechar un dueño destruye recursivamente todo lo que nació dentro, y lo hace en un orden concreto que importa. La implementación completa, el orden de destrucción y la limpieza entre reejecuciones.

⏱ 17 min

Con el árbol construido, la destrucción es un recorrido. Desechar un nodo significa desechar a sus hijos, ejecutar sus limpiezas y desatar sus aristas, en ese orden y no en otro. La operación cabe en diez líneas, y la razón por la que el orden es ese —hijos primero, padre después— es la misma que en cualquier sistema de destrucción de recursos.

🎯 Al terminar esta lección sabrás
  • Implementar el desecho recursivo de un ámbito.
  • Justificar el orden de destrucción de hijos, limpiezas y aristas.
  • Distinguir la limpieza entre reejecuciones del desecho definitivo.
  • Reconocer los errores clásicos al implementar la cascada.

La implementación

Dos funciones. Una limpia el contenido de un nodo; la otra lo desecha del todo.

function limpiar(nodo) {
  for (const hijo of nodo.hijos) desechar(hijo);   // 1. los hijos primero
  nodo.hijos.length = 0;
  for (const fn of nodo.limpiezas) fn();            // 2. luego mis limpiezas
  nodo.limpiezas.length = 0;
}

function desechar(nodo) {
  limpiar(nodo);                                    // 3. contenido destruido
  if (nodo.fuentes) desatar(nodo);                  // 4. fuera del grafo
  nodo.estado = LIMPIO;
}

Es todo. La recursión la aporta desechar llamando a limpiar, que a su vez llama a desechar sobre cada hijo. Un árbol de mil nodos se destruye entero con una llamada.

Por qué los hijos antes que las limpiezas

El orden no es arbitrario y tiene la misma justificación que en cualquier sistema de destrucción por ámbitos: un hijo puede depender de recursos que la limpieza del padre va a liberar.

efecto(() => {
  const socket = new WebSocket(url());
  alLimpiar(() => socket.close());          // limpieza del padre

  efecto(() => {                             // hijo
    const id = setInterval(() => socket.send(ping()), 1000);
    alLimpiar(() => clearInterval(id));      // limpieza del hijo
  });
});

Si la limpieza del padre corriera primero, el socket se cerraría mientras el intervalo del hijo sigue vivo, y la siguiente ejecución intentaría enviar por un socket cerrado. Destruyendo primero al hijo, el intervalo se cancela antes de que el socket se cierre.

Es exactamente el orden inverso a la construcción, que es la regla universal de la destrucción de recursos: lo último que se creó es lo primero que se destruye.

flowchart TB
D[desechar el nodo] --> H[desechar hijos recursivamente]
H --> H2[cada hijo desecha a los suyos primero]
H2 --> L[ejecutar las limpiezas del nodo]
L --> A[desatar las aristas del nodo]
A --> F[el nodo ya no existe para el sistema]
style D fill:#f38ba8,color:#11111b
style H fill:#f9e2af,color:#11111b
style H2 fill:#f9e2af,color:#11111b
style L fill:#fab387,color:#11111b
style A fill:#89b4fa,color:#11111b
style F fill:#a6e3a1,color:#11111b

Limpieza entre reejecuciones

Aquí está el uso más frecuente y el que más se subestima. Una computación no solo se limpia al morir: se limpia antes de cada reejecución.

function actualizar(nodo) {
  // ...
  limpiar(nodo);      // <-- destruye lo que creo la pasada anterior
  desatar(nodo);
  // ... ejecutar el cuerpo, que creara cosas nuevas
}

La razón es la misma que la del desatado del nivel 3: cada ejecución crea recursos nuevos, y los de la pasada anterior tienen que desaparecer. Sin esta limpieza, un efecto que crea un temporizador acumularía un temporizador por ejecución.

efecto(() => {
  const id = setInterval(() => refrescar(canal()), 1000);
  alLimpiar(() => clearInterval(id));
});
// cada vez que canal() cambia: se cancela el intervalo viejo y se crea uno nuevo

Sin la línea de limpieza, cambiar de canal diez veces dejaría diez intervalos activos, todos disparando. Es la fuga más común de los sistemas reactivos y la que este mecanismo hace desaparecer casi sin que el programador lo note.

💡
La limpieza es el sitio natural para cancelar peticiones

El caso asíncrono del nivel 3 se resuelve aquí. Un efecto que lanza una petición debe abortarla si se reejecuta, y el sitio para hacerlo es la limpieza. El resultado es que la condición de carrera desaparece: la petición vieja se cancela antes de que empiece la nueva.

efecto(() => {
  const control = new AbortController();
  alLimpiar(() => control.abort());
  fetch(`/api/${id()}`, { signal: control.signal })
    .then(r => r.json())
    .then(d => datos.set(d))
    .catch(e => { if (e.name !== 'AbortError') throw e; });
});

Errores clásicos y coste

Los cuatro errores clásicos

Limpiar después de ejecutar en vez de antes. El recurso nuevo se crea mientras el viejo sigue vivo, y durante ese intervalo hay dos. Con sockets o escuchadores de eventos eso significa duplicados observables.

No vaciar las listas. Si limpiar ejecuta las limpiezas pero no vacía el array, la próxima limpieza las ejecuta otra vez. Cancelar un intervalo ya cancelado es inofensivo; cerrar un socket ya cerrado o abortar dos veces puede no serlo.

Registrar la limpieza en el dueño equivocado. Si la primitiva de limpieza se llama fuera de una computación, se registra en el dueño externo y se ejecuta mucho más tarde de lo previsto, o nunca. Es especialmente fácil de cometer dentro de un callback asíncrono, donde el dueño actual ya no es el que crees.

Lanzar una excepción dentro de una limpieza. Si una limpieza falla, las siguientes no se ejecutan y el árbol queda a medio destruir. Un motor robusto envuelve cada limpieza para que un fallo no impida las demás.

function limpiar(nodo) {
  for (const hijo of nodo.hijos) desechar(hijo);
  nodo.hijos.length = 0;
  for (const fn of nodo.limpiezas) {
    try { fn(); } catch (e) { console.error('fallo en limpieza', e); }
  }
  nodo.limpiezas.length = 0;
}
La limpieza en cascada convierte la vida de un recurso en la vida de un ambito

Lo que este mecanismo consigue, dicho con precisión, es atar la vida de cualquier recurso a la vida de un ámbito léxico, en un lenguaje que no tiene ninguna forma de hacer eso. Escribes alLimpiar junto al código que crea el recurso, y a partir de ahí el sistema garantiza que se libera cuando el ámbito muera, sin que nadie lleve la cuenta, sin que nadie tenga que acordarse, y con independencia de qué camino de ejecución provoque la muerte. Es exactamente la propiedad que dan los destructores en un lenguaje con propiedad, y el motivo por el que la programación con recursos es tranquila en esos lenguajes y ansiosa en JavaScript. Hay una implicación de diseño que se aprovecha poco: como el mecanismo es genérico, sirve para cualquier recurso, no solo para lo reactivo. Cualquier cosa con adquisición y liberación cabe aquí: un observador de intersección, un bloqueo de pantalla, un contexto de audio, una transacción de base de datos, un temporizador. Y hay un corolario que ahorra muchos disgustos: si escribes una función que adquiere un recurso y esa función se va a llamar desde un ámbito reactivo, registra la liberación dentro de la propia función en vez de devolverla para que el llamador la registre. Así la simetría queda encapsulada donde está el conocimiento, y ningún llamador puede olvidarse. Es la misma disciplina que un defer o un with, y da los mismos frutos.

Coste de la cascada

Destruir un árbol de n nodos cuesta O(n + aristas): se visita cada nodo una vez y se desata cada arista una vez. En una aplicación que navega entre pantallas, esa destrucción ocurre en el momento de la transición y puede notarse si el árbol es enorme.

La mitigación habitual no es acelerar la destrucción sino repartirla: destruir el árbol viejo en un turno posterior, después de haber pintado el nuevo. Es una decisión del framework más que del motor, y tiene el riesgo evidente de que durante un instante conviven dos árboles.

⚔️ Verifica el orden y provoca las fugas
  1. Construye un efecto con un hijo, cada uno con su limpieza, y registra el orden real de ejecución.
  2. Invierte el orden en tu implementación y encuentra un caso donde eso produzca un error observable.
  3. Escribe un efecto que cree un intervalo sin limpieza y comprueba cuántos quedan activos tras diez cambios.
  4. Haz que una limpieza lance una excepción y verifica si las siguientes se ejecutan.