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.
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.
- 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.
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;
}
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.
- Construye un efecto con un hijo, cada uno con su limpieza, y registra el orden real de ejecución.
- Invierte el orden en tu implementación y encuentra un caso donde eso produzca un error observable.
- Escribe un efecto que cree un intervalo sin limpieza y comprueba cuántos quedan activos tras diez cambios.
- Haz que una limpieza lance una excepción y verifica si las siguientes se ejecutan.